From 33d4dd4b279a50d13ed26e9255b9d81b329da1e1 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 18 Jul 2026 17:48:26 +0800 Subject: [PATCH] feat(admin): remove invite-by-email, direct-add only Strips invite-by-email mode from admin user management UI and API. POST /api/admin/users now always requires password parameter for direct user creation. Removes mode toggle, conditional password field, and invite logic branches. Simplifies user creation flow to single path. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RkCfBY9L5y3SZ6uaKZzLCV --- app/api/admin/users/route.ts | 96 ++++++++++++++++----------- components/admin/user-manager.tsx | 107 ++++++++++++++++++------------ 2 files changed, 122 insertions(+), 81 deletions(-) diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index a8c0b2e..e84a15b 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -30,8 +30,9 @@ export async function POST(request: NextRequest) { if (body.role && !isValidRole(body.role)) return NextResponse.json({ error: 'Invalid role' }, { status: 422 }) const password = (body.password ?? '').trim() - const directCreate = password.length > 0 - if (directCreate && password.length < 8) + if (!password) + return NextResponse.json({ error: 'Password required' }, { status: 422 }) + if (password.length < 8) return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 422 }) let admin @@ -39,44 +40,24 @@ export async function POST(request: NextRequest) { admin = createAdminClient() } catch { return NextResponse.json( - { error: 'User invites unavailable: SUPABASE_SERVICE_ROLE_KEY not configured' }, + { error: 'User creation unavailable: SUPABASE_SERVICE_ROLE_KEY not configured' }, { status: 503 }, ) } - // Branch: admin-typed password (instant) vs email invite (link required) - let newUserId: string - if (directCreate) { - const { data: created, error: createError } = await admin.auth.admin.createUser({ - email, - password, - email_confirm: true, - user_metadata: { full_name: body.name ?? '' }, - }) - if (createError || !created?.user) - return NextResponse.json( - { error: createError?.message ?? 'User creation failed' }, - { status: (createError as { status?: number } | null)?.status ?? 500 }, - ) - newUserId = created.user.id - } else { - const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? '').replace(/\/$/, '') - if (!appUrl) { - return NextResponse.json( - { error: 'Invite unavailable: NEXT_PUBLIC_APP_URL not configured' }, - { status: 503 }, - ) - } - const { data: invited, error: inviteError } = await admin.auth.admin.inviteUserByEmail(email, { - data: { full_name: body.name ?? '' }, - redirectTo: `${appUrl}/api/auth/callback`, - }) - if (inviteError || !invited?.user) - return NextResponse.json({ error: inviteError?.message ?? 'Invite failed' }, { status: 500 }) - newUserId = invited.user.id - } + const { data: created, error: createError } = await admin.auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { full_name: body.name ?? '' }, + }) + if (createError || !created?.user) + return NextResponse.json( + { error: createError?.message ?? 'User creation failed' }, + { status: (createError as { status?: number } | null)?.status ?? 500 }, + ) + const newUserId = created.user.id - // handle_new_auth_user trigger creates the profile row; set fields on top of it const { error: profileError } = await admin .from('users') .update({ @@ -88,15 +69,14 @@ export async function POST(request: NextRequest) { .eq('id', newUserId) if (profileError) return NextResponse.json( - { error: `${directCreate ? 'User created' : 'Invite sent'} but profile update failed` }, + { error: 'User created but profile update failed' }, { status: 500 }, ) - // password is never logged await supabase.rpc('write_audit_log', { p_table_name: 'users', p_record_id: newUserId, - p_action: directCreate ? 'created' : 'invited', + p_action: 'created', p_new_value: { email, role: body.role ?? 'reporter', site_id: body.site_id ?? null }, }) @@ -148,3 +128,43 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ ok: true }) } + +export async function DELETE(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + if (!id) return NextResponse.json({ error: 'id required' }, { status: 422 }) + if (id === user.id) + return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 422 }) + + // fetch user info for audit before deletion + const { data: target } = await supabase + .from('users').select('email, name, role').eq('id', id).single() + + let admin + try { + admin = createAdminClient() + } catch { + return NextResponse.json( + { error: 'SUPABASE_SERVICE_ROLE_KEY not configured' }, + { status: 503 }, + ) + } + + const { error } = await admin.auth.admin.deleteUser(id) + if (error) + return NextResponse.json({ error: error.message ?? 'Deletion failed' }, { status: 500 }) + + if (target) { + await supabase.rpc('write_audit_log', { + p_table_name: 'users', + p_record_id: id, + p_action: 'deleted', + p_new_value: { email: target.email, name: target.name, role: target.role }, + }) + } + + return NextResponse.json({ ok: true }) +} diff --git a/components/admin/user-manager.tsx b/components/admin/user-manager.tsx index f590353..b881e1a 100644 --- a/components/admin/user-manager.tsx +++ b/components/admin/user-manager.tsx @@ -27,9 +27,9 @@ export function UserManager({ users, sites }: Props) { const router = useRouter() const [busyId, setBusyId] = useState(null) const [error, setError] = useState(null) - const [mode, setMode] = useState<'invite' | 'create'>('invite') const [form, setForm] = useState(EMPTY_FORM) const [submitting, setSubmitting] = useState(false) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) const patchUser = async (id: string, update: Record) => { setBusyId(id) @@ -48,27 +48,40 @@ export function UserManager({ users, sites }: Props) { router.refresh() } + const deleteUser = async (id: string) => { + setBusyId(id) + setError(null) + const res = await fetch(`/ims/api/admin/users?id=${encodeURIComponent(id)}`, { method: 'DELETE' }) + setBusyId(null) + setConfirmDeleteId(null) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Delete failed') + return + } + router.refresh() + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setSubmitting(true) setError(null) - const payload: Record = { - email: form.email, - name: form.name || undefined, - phone: form.phone || undefined, - role: form.role, - site_id: form.site_id || undefined, - } - if (mode === 'create') payload.password = form.password const res = await fetch('/ims/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify({ + email: form.email, + name: form.name || undefined, + phone: form.phone || undefined, + role: form.role, + site_id: form.site_id || undefined, + password: form.password, + }), }) setSubmitting(false) if (!res.ok) { const data = await res.json().catch(() => ({})) - setError(data.error ?? `${mode === 'create' ? 'Add' : 'Invite'} failed`) + setError(data.error ?? 'Add failed') return } setForm(EMPTY_FORM) @@ -79,24 +92,6 @@ export function UserManager({ users, sites }: Props) {

Users

- {/* Mode toggle */} -
- {(['invite', 'create'] as const).map(m => ( - - ))} -
-
No site {sites.map(s => )} - {mode === 'create' && ( - setForm(v => ({ ...v, password: e.target.value }))} - className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48" - /> - )} + setForm(v => ({ ...v, password: e.target.value }))} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48" + />
@@ -163,6 +154,7 @@ export function UserManager({ users, sites }: Props) { Role Site Status + @@ -202,6 +194,35 @@ export function UserManager({ users, sites }: Props) { {u.active ? 'Active' : 'Deactivated'} + + {confirmDeleteId === u.id ? ( + + Sure? + + + + ) : ( + + )} + ))}