From e949016bd46dba00f2ae752aeab62e54a926a958 Mon Sep 17 00:00:00 2001 From: weeihan Date: Mon, 13 Jul 2026 16:36:20 +0800 Subject: [PATCH] feat(admin): add users directly with admin-set password + phone field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/admin/users: if password supplied → createUser (instant, email_confirm:true); else inviteUserByEmail (existing flow) - password validated ≥8 chars; never logged in audit_log - profile update now also saves phone number - UI: mode toggle (Invite by email / Add directly), phone input always visible, password input shown only in 'Add directly' mode Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- app/api/admin/users/route.ts | 51 +++++++++++++----- components/admin/user-manager.tsx | 86 ++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index d45e278..3cdfc06 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -22,13 +22,17 @@ export async function POST(request: NextRequest) { const { supabase, user } = await requireAdmin() if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const body: { email?: string; name?: string; role?: string; site_id?: string } = + const body: { email?: string; name?: string; phone?: string; role?: string; site_id?: string; password?: string } = await request.json().catch(() => ({})) const email = (body.email ?? '').trim().toLowerCase() if (!email || !email.includes('@')) return NextResponse.json({ error: 'Valid email required' }, { status: 422 }) 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) + return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 422 }) let admin try { @@ -40,32 +44,55 @@ export async function POST(request: NextRequest) { ) } - const { data: invited, error: inviteError } = await admin.auth.admin.inviteUserByEmail(email, { - data: { full_name: body.name ?? '' }, - }) - if (inviteError || !invited?.user) - return NextResponse.json({ error: inviteError?.message ?? 'Invite failed' }, { status: 500 }) + // 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 { data: invited, error: inviteError } = await admin.auth.admin.inviteUserByEmail(email, { + data: { full_name: body.name ?? '' }, + }) + if (inviteError || !invited?.user) + return NextResponse.json({ error: inviteError?.message ?? 'Invite failed' }, { status: 500 }) + newUserId = invited.user.id + } - // handle_new_auth_user trigger creates the profile row; set role/site on top of it + // handle_new_auth_user trigger creates the profile row; set fields on top of it const { error: profileError } = await admin .from('users') .update({ name: body.name ?? '', + phone: (body.phone ?? '').trim() || null, role: body.role ?? 'reporter', site_id: body.site_id ?? null, }) - .eq('id', invited.user.id) + .eq('id', newUserId) if (profileError) - return NextResponse.json({ error: 'Invite sent but profile update failed' }, { status: 500 }) + return NextResponse.json( + { error: `${directCreate ? 'User created' : 'Invite sent'} but profile update failed` }, + { status: 500 }, + ) + // password is never logged await supabase.rpc('write_audit_log', { p_table_name: 'users', - p_record_id: invited.user.id, - p_action: 'invited', + p_record_id: newUserId, + p_action: directCreate ? 'created' : 'invited', p_new_value: { email, role: body.role ?? 'reporter', site_id: body.site_id ?? null }, }) - return NextResponse.json({ id: invited.user.id }, { status: 201 }) + return NextResponse.json({ id: newUserId }, { status: 201 }) } export async function PATCH(request: NextRequest) { diff --git a/components/admin/user-manager.tsx b/components/admin/user-manager.tsx index d5c478b..f590353 100644 --- a/components/admin/user-manager.tsx +++ b/components/admin/user-manager.tsx @@ -21,12 +21,15 @@ interface Props { sites: SiteOption[] } +const EMPTY_FORM = { email: '', name: '', phone: '', role: 'reporter' as UserRole, site_id: '', password: '' } + export function UserManager({ users, sites }: Props) { const router = useRouter() const [busyId, setBusyId] = useState(null) const [error, setError] = useState(null) - const [invite, setInvite] = useState({ email: '', name: '', role: 'reporter' as UserRole, site_id: '' }) - const [inviting, setInviting] = useState(false) + const [mode, setMode] = useState<'invite' | 'create'>('invite') + const [form, setForm] = useState(EMPTY_FORM) + const [submitting, setSubmitting] = useState(false) const patchUser = async (id: string, update: Record) => { setBusyId(id) @@ -45,22 +48,30 @@ export function UserManager({ users, sites }: Props) { router.refresh() } - const sendInvite = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setInviting(true) + 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({ ...invite, site_id: invite.site_id || undefined }), + body: JSON.stringify(payload), }) - setInviting(false) + setSubmitting(false) if (!res.ok) { const data = await res.json().catch(() => ({})) - setError(data.error ?? 'Invite failed') + setError(data.error ?? `${mode === 'create' ? 'Add' : 'Invite'} failed`) return } - setInvite({ email: '', name: '', role: 'reporter', site_id: '' }) + setForm(EMPTY_FORM) router.refresh() } @@ -68,39 +79,76 @@ export function UserManager({ users, sites }: Props) {

Users

-
+ {/* Mode toggle */} +
+ {(['invite', 'create'] as const).map(m => ( + + ))} +
+ + setInvite(v => ({ ...v, email: e.target.value }))} + value={form.email} + onChange={e => setForm(v => ({ ...v, email: e.target.value }))} className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-52" /> setInvite(v => ({ ...v, name: e.target.value }))} + value={form.name} + onChange={e => setForm(v => ({ ...v, name: e.target.value }))} className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-40" /> + setForm(v => ({ ...v, phone: e.target.value }))} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-44" + /> + {mode === 'create' && ( + setForm(v => ({ ...v, password: e.target.value }))} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48" + /> + )}