feat: [US-016] - Admin - invite new user
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2159414804
commit
add18ee10a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue