Update dependencies and enhance admin account page

This commit updates various dependencies in pnpm-lock file and introduces enhancements to the admin account page. This includes adding several new functionality like 'Delete User', 'Ban User', 'Impersonate User' and 'Delete Account'. Various UI components are also added for these features.
This commit is contained in:
giancarlo
2024-04-09 15:26:31 +08:00
parent e7f2660032
commit 19c16cfb44
15 changed files with 1150 additions and 244 deletions

View File

@@ -0,0 +1,127 @@
'use server';
import { revalidatePath } from 'next/cache';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { enhanceAdminAction } from './enhance-admin-action';
import {
BanUserSchema,
DeleteAccountSchema,
DeleteUserSchema,
ImpersonateUserSchema,
ReactivateUserSchema,
} from './schema/admin-actions.schema';
import { AdminAccountsService } from './services/admin-accounts.service';
import { AdminAuthUserService } from './services/admin-auth-user.service';
/**
* @name banUser
* @description Ban a user from the system.
*/
export const banUser = enhanceAdminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
await service.banUser(userId);
revalidateAdmin();
},
{
schema: BanUserSchema,
},
),
);
/**
* @name reactivateUser
* @description Reactivate a user in the system.
*/
export const reactivateUser = enhanceAdminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
await service.reactivateUser(userId);
revalidateAdmin();
},
{
schema: ReactivateUserSchema,
},
),
);
/**
* @name impersonateUser
* @description Impersonate a user in the system.
*/
export const impersonateUser = enhanceAdminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
return await service.impersonateUser(userId);
},
{
schema: ImpersonateUserSchema,
},
),
);
/**
* @name deleteUser
* @description Delete a user from the system.
*/
export const deleteUser = enhanceAdminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
await service.deleteUser(userId);
revalidateAdmin();
},
{
schema: DeleteUserSchema,
},
),
);
/**
* @name deleteAccount
* @description Delete an account from the system.
*/
export const deleteAccount = enhanceAdminAction(
enhanceAction(
async ({ accountId }) => {
const service = getAdminAccountsService();
await service.deleteAccount(accountId);
revalidateAdmin();
},
{
schema: DeleteAccountSchema,
},
),
);
function getAdminAuthService() {
const client = getSupabaseServerActionClient();
const adminClient = getSupabaseServerActionClient({ admin: true });
return new AdminAuthUserService(client, adminClient);
}
function getAdminAccountsService() {
const adminClient = getSupabaseServerActionClient({ admin: true });
return new AdminAccountsService(adminClient);
}
function revalidateAdmin() {
revalidatePath('/admin', 'layout');
}

View File

@@ -0,0 +1,19 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { isSuperAdmin } from './is-super-admin';
export function enhanceAdminAction<Args, Response>(
fn: (params: Args) => Response,
) {
return async (params: Args) => {
const isAdmin = await isSuperAdmin(getSupabaseServerActionClient());
if (!isAdmin) {
notFound();
}
return fn(params);
};
}

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
const confirmationSchema = z.object({
confirmation: z.custom((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(),
});

View File

@@ -0,0 +1,18 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
export 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;
}
}
}

View File

@@ -0,0 +1,116 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* @name AdminAuthUserService
* @description Service for performing admin actions on users in the system.
* This service only interacts with the Supabase Auth Admin API.
*/
export class AdminAuthUserService {
constructor(
private readonly client: SupabaseClient<Database>,
private readonly adminClient: SupabaseClient<Database>,
) {}
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`,
);
}
}
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.`);
}
}
async banUser(userId: string) {
await this.assertUserIsNotCurrentSuperAdmin(userId);
return this.setBanDuration(userId, `876600h`);
}
async reactivateUser(userId: string) {
await this.assertUserIsNotCurrentSuperAdmin(userId);
return this.setBanDuration(userId, `none`);
}
async impersonateUser(userId: string) {
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,
};
}
private async setBanDuration(userId: string, banDuration: string) {
await this.adminClient.auth.admin.updateUserById(userId, {
ban_duration: banDuration,
});
}
}