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" + /> + )}