B2B-88: add starter kit structure and elements
This commit is contained in:
240
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
240
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
BanUserSchema,
|
||||
DeleteAccountSchema,
|
||||
DeleteUserSchema,
|
||||
ImpersonateUserSchema,
|
||||
ReactivateUserSchema,
|
||||
} from './schema/admin-actions.schema';
|
||||
import { CreateUserSchema } from './schema/create-user.schema';
|
||||
import { ResetPasswordSchema } from './schema/reset-password.schema';
|
||||
import { createAdminAccountsService } from './services/admin-accounts.service';
|
||||
import { createAdminAuthUserService } from './services/admin-auth-user.service';
|
||||
import { adminAction } from './utils/admin-action';
|
||||
|
||||
/**
|
||||
* @name banUserAction
|
||||
* @description Ban a user from the system.
|
||||
*/
|
||||
export const banUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is banning user...`);
|
||||
|
||||
await service.banUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully banned user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name reactivateUserAction
|
||||
* @description Reactivate a user in the system.
|
||||
*/
|
||||
export const reactivateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is reactivating user...`);
|
||||
|
||||
await service.reactivateUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name impersonateUserAction
|
||||
* @description Impersonate a user in the system.
|
||||
*/
|
||||
export const impersonateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is impersonating user...`);
|
||||
|
||||
return await service.impersonateUser(userId);
|
||||
},
|
||||
{
|
||||
schema: ImpersonateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteUserAction
|
||||
* @description Delete a user from the system.
|
||||
*/
|
||||
export const deleteUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is deleting user...`);
|
||||
|
||||
await service.deleteUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully deleted user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteAccountAction
|
||||
* @description Delete an account from the system.
|
||||
*/
|
||||
export const deleteAccountAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ accountId }) => {
|
||||
const service = getAdminAccountsService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ accountId }, `Super Admin is deleting account...`);
|
||||
|
||||
await service.deleteAccount(accountId);
|
||||
|
||||
logger.info(
|
||||
{ accountId },
|
||||
`Super Admin has successfully deleted account`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name createUserAction
|
||||
* @description Create a new user in the system.
|
||||
*/
|
||||
export const createUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ email, password, emailConfirm }) => {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ email }, `Super Admin is creating a new user...`);
|
||||
|
||||
const { data, error } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: emailConfirm,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, `Error creating user`);
|
||||
throw new Error(`Error creating user: ${error.message}`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: data.user.id },
|
||||
`Super Admin has successfully created a new user`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: data.user,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name resetPasswordAction
|
||||
* @description Reset a user's password by sending a password reset email.
|
||||
*/
|
||||
export const resetPasswordAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is resetting user password...`);
|
||||
|
||||
const result = await service.resetPassword(userId);
|
||||
|
||||
logger.info(
|
||||
{ userId },
|
||||
`Super Admin has successfully sent password reset email`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return result;
|
||||
},
|
||||
{
|
||||
schema: ResetPasswordSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
function getAdminAuthService() {
|
||||
const client = getSupabaseServerClient();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
return createAdminAuthUserService(client, adminClient);
|
||||
}
|
||||
|
||||
function getAdminAccountsService() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
return createAdminAccountsService(adminClient);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createAdminDashboardService } from '../services/admin-dashboard.service';
|
||||
|
||||
/**
|
||||
* @name loadAdminDashboard
|
||||
* @description Load the admin dashboard data.
|
||||
* @param params
|
||||
*/
|
||||
export const loadAdminDashboard = cache(adminDashboardLoader);
|
||||
|
||||
function adminDashboardLoader() {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAdminDashboardService(client);
|
||||
|
||||
return service.getDashboardData();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfirmationSchema = z.object({
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
|
||||
const UserIdSchema = ConfirmationSchema.extend({
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const BanUserSchema = UserIdSchema;
|
||||
export const ReactivateUserSchema = UserIdSchema;
|
||||
export const ImpersonateUserSchema = UserIdSchema;
|
||||
export const DeleteUserSchema = UserIdSchema;
|
||||
|
||||
export const DeleteAccountSchema = ConfirmationSchema.extend({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: 'Password must be at least 8 characters' }),
|
||||
emailConfirm: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for resetting a user's password
|
||||
*/
|
||||
export const ResetPasswordSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminAccountsService(client: SupabaseClient<Database>) {
|
||||
return new AdminAccountsService(client);
|
||||
}
|
||||
|
||||
class AdminAccountsService {
|
||||
constructor(private adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
async deleteAccount(accountId: string) {
|
||||
const { error } = await this.adminClient
|
||||
.from('accounts')
|
||||
.delete()
|
||||
.eq('id', accountId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminAuthUserService(
|
||||
client: SupabaseClient<Database>,
|
||||
adminClient: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AdminAuthUserService(client, adminClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name AdminAuthUserService
|
||||
* @description Service for performing admin actions on users in the system.
|
||||
* This service only interacts with the Supabase Auth Admin API.
|
||||
*/
|
||||
class AdminAuthUserService {
|
||||
constructor(
|
||||
private readonly client: SupabaseClient<Database>,
|
||||
private readonly adminClient: SupabaseClient<Database>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Delete a user by deleting the user record and auth record.
|
||||
* @param userId
|
||||
*/
|
||||
async deleteUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const deleteUserResponse =
|
||||
await this.adminClient.auth.admin.deleteUser(userId);
|
||||
|
||||
if (deleteUserResponse.error) {
|
||||
throw new Error(`Error deleting user record or auth record.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban a user by setting the ban duration to `876600h` (100 years).
|
||||
* @param userId
|
||||
*/
|
||||
async banUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `876600h`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a user by setting the ban duration to `none`.
|
||||
* @param userId
|
||||
*/
|
||||
async reactivateUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `none`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Impersonate a user by generating a magic link and returning the access and refresh tokens.
|
||||
* @param userId
|
||||
*/
|
||||
async impersonateUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await this.adminClient.auth.admin.getUserById(userId);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot impersonate`);
|
||||
}
|
||||
|
||||
const { error: linkError, data } =
|
||||
await this.adminClient.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email,
|
||||
options: {
|
||||
redirectTo: `/`,
|
||||
},
|
||||
});
|
||||
|
||||
if (linkError ?? !data) {
|
||||
throw new Error(`Error generating magic link`);
|
||||
}
|
||||
|
||||
const response = await fetch(data.properties?.action_link, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
|
||||
if (!location) {
|
||||
throw new Error(`Error generating magic link. Location header not found`);
|
||||
}
|
||||
|
||||
const hash = new URL(location).hash.substring(1);
|
||||
const query = new URLSearchParams(hash);
|
||||
const accessToken = query.get('access_token');
|
||||
const refreshToken = query.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error(
|
||||
`Error generating magic link. Tokens not found in URL hash.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the target user is not the current user.
|
||||
* @param targetUserId
|
||||
*/
|
||||
private async assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
|
||||
const { data: user } = await this.client.auth.getUser();
|
||||
const currentUserId = user.user?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on your own account as a Super Admin`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser =
|
||||
await this.adminClient.auth.admin.getUserById(targetUserId);
|
||||
|
||||
const targetUserRole = targetUser.data.user?.app_metadata?.role;
|
||||
|
||||
if (targetUserRole === 'super-admin') {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on a Super Admin account`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setBanDuration(userId: string, banDuration: string) {
|
||||
await this.adminClient.auth.admin.updateUserById(userId, {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a user's password by sending a password reset email.
|
||||
* @param userId
|
||||
*/
|
||||
async resetPassword(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await this.adminClient.auth.admin.getUserById(userId);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot reset password`);
|
||||
}
|
||||
|
||||
// Get the site URL from environment variable
|
||||
const siteUrl = z.string().url().parse(process.env.NEXT_PUBLIC_SITE_URL);
|
||||
|
||||
const redirectTo = `${siteUrl}/update-password`;
|
||||
|
||||
const { error: resetError } =
|
||||
await this.adminClient.auth.resetPasswordForEmail(email, {
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
throw new Error(
|
||||
`Error sending password reset email: ${resetError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminDashboardService(client: SupabaseClient<Database>) {
|
||||
return new AdminDashboardService(client);
|
||||
}
|
||||
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* Get the dashboard data for the admin dashboard
|
||||
* @param count
|
||||
*/
|
||||
async getDashboardData(
|
||||
{ count }: { count: 'exact' | 'estimated' | 'planned' } = {
|
||||
count: 'estimated',
|
||||
},
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
const ctx = {
|
||||
name: `admin.dashboard`,
|
||||
};
|
||||
|
||||
const selectParams = {
|
||||
count,
|
||||
head: true,
|
||||
};
|
||||
|
||||
const subscriptionsPromise = this.client
|
||||
.from('subscriptions')
|
||||
.select('*', selectParams)
|
||||
.eq('status', 'active')
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching active subscriptions`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const trialsPromise = this.client
|
||||
.from('subscriptions')
|
||||
.select('*', selectParams)
|
||||
.eq('status', 'trialing')
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching trialing subscriptions`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const accountsPromise = this.client
|
||||
.from('accounts')
|
||||
.select('*', selectParams)
|
||||
.eq('is_personal_account', true)
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching personal accounts`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const teamAccountsPromise = this.client
|
||||
.from('accounts')
|
||||
.select('*', selectParams)
|
||||
.eq('is_personal_account', false)
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching team accounts`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const [subscriptions, trials, accounts, teamAccounts] = await Promise.all([
|
||||
subscriptionsPromise,
|
||||
trialsPromise,
|
||||
accountsPromise,
|
||||
teamAccountsPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
trials,
|
||||
accounts,
|
||||
teamAccounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
packages/features/admin/src/lib/server/utils/admin-action.ts
Normal file
22
packages/features/admin/src/lib/server/utils/admin-action.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isSuperAdmin } from './is-super-admin';
|
||||
|
||||
/**
|
||||
* @name adminAction
|
||||
* @description Wrap a server action to ensure the user is a super admin.
|
||||
* @param fn
|
||||
*/
|
||||
export function adminAction<Args, Response>(fn: (params: Args) => Response) {
|
||||
return async (params: Args) => {
|
||||
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return fn(params);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name isSuperAdmin
|
||||
* @description Check if the current user is a super admin.
|
||||
* @param client
|
||||
*/
|
||||
export async function isSuperAdmin(client: SupabaseClient<Database>) {
|
||||
try {
|
||||
const { data, error } = await client.rpc('is_super_admin');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user