feat: [US-016] - Admin - invite new user

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-21 04:25:09 -03:00
parent 2159414804
commit add18ee10a
6 changed files with 206 additions and 2 deletions

View File

@ -1,3 +1,7 @@
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Site URL (used for redirect URLs in emails)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

View File

@ -0,0 +1,87 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { inviteUser } from './actions'
export default function InviteForm() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSuccess(false)
setLoading(true)
const result = await inviteUser(email)
if (!result.success) {
setError(result.error || 'Failed to send invitation')
setLoading(false)
return
}
setSuccess(true)
setEmail('')
setLoading(false)
}
return (
<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form onSubmit={handleSubmit} className="space-y-6">
{success && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-700 dark:text-green-400">
Invitation sent successfully! The user will receive an email with instructions to complete their account setup.
</p>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="user@example.com"
/>
</div>
<div className="flex items-center justify-between gap-4">
<Link
href="/dashboard"
className="text-sm font-medium text-zinc-600 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300"
>
Back to Dashboard
</Link>
<button
type="submit"
disabled={loading}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Sending...' : 'Send Invitation'}
</button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,54 @@
'use server'
import { createClient } from '@supabase/supabase-js'
import { createClient as createServerClient } from '@/lib/supabase/server'
export async function inviteUser(email: string): Promise<{ success: boolean; error?: string }> {
// First, verify the current user is an admin
const supabase = await createServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'You must be logged in to invite users' }
}
// Check if user is admin
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single()
if (profileError || !profile?.is_admin) {
return { success: false, error: 'You do not have permission to invite users' }
}
// Create admin client with service role key for inviting users
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!serviceRoleKey) {
return { success: false, error: 'Server configuration error: missing service role key' }
}
const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
serviceRoleKey,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
// Invite the user
const { error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/signup`,
})
if (inviteError) {
return { success: false, error: inviteError.message }
}
return { success: true }
}

View File

@ -0,0 +1,41 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import InviteForm from './InviteForm'
export default async function AdminInvitePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// Check if user is admin
const { data: profile } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single()
if (!profile?.is_admin) {
redirect('/dashboard')
}
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
Invite New User
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Send an invitation email to a new user to give them access to WebVNWrite.
</p>
</div>
<InviteForm />
</div>
</div>
)
}

View File

@ -17,9 +17,18 @@ export default async function DashboardLayout({
redirect('/login')
}
// Fetch user profile to check admin status
const { data: profile } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single()
const isAdmin = profile?.is_admin || false
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar userEmail={user.email || ''} />
<Navbar userEmail={user.email || ''} isAdmin={isAdmin} />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{children}
</main>

View File

@ -3,9 +3,10 @@ import LogoutButton from './LogoutButton'
interface NavbarProps {
userEmail: string
isAdmin?: boolean
}
export default function Navbar({ userEmail }: NavbarProps) {
export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
return (
<nav className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
@ -20,6 +21,14 @@ export default function Navbar({ userEmail }: NavbarProps) {
</div>
<div className="flex items-center gap-4">
{isAdmin && (
<Link
href="/admin/invite"
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Invite User
</Link>
)}
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{userEmail}
</span>