feat(auth): add organization invite links
Shareable invite codes (e.g. hps-k7m2x9) let anyone join an org after authenticating. Admins create/revoke links from Settings > Team. Public /join/[code] route handles acceptance with expiry and max-use limits.
This commit is contained in:
parent
4fc952cddd
commit
7ee5304176
33
drizzle/0028_small_old_lace.sql
Normal file
33
drizzle/0028_small_old_lace.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE `organization_invites` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`organization_id` text NOT NULL,
|
||||||
|
`code` text NOT NULL,
|
||||||
|
`role` text DEFAULT 'office' NOT NULL,
|
||||||
|
`max_uses` integer,
|
||||||
|
`use_count` integer DEFAULT 0 NOT NULL,
|
||||||
|
`expires_at` text,
|
||||||
|
`created_by` text NOT NULL,
|
||||||
|
`is_active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `organization_invites_code_unique` ON `organization_invites` (`code`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `mcp_servers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`org_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`slug` text NOT NULL,
|
||||||
|
`transport` text NOT NULL,
|
||||||
|
`command` text,
|
||||||
|
`args` text,
|
||||||
|
`env` text,
|
||||||
|
`url` text,
|
||||||
|
`headers` text,
|
||||||
|
`is_enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`created_by` text NOT NULL,
|
||||||
|
FOREIGN KEY (`org_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
5344
drizzle/meta/0028_snapshot.json
Normal file
5344
drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -197,6 +197,13 @@
|
|||||||
"when": 1771282232152,
|
"when": 1771282232152,
|
||||||
"tag": "0027_flowery_hulk",
|
"tag": "0027_flowery_hulk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771295883108,
|
||||||
|
"tag": "0028_small_old_lace",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
39
src/app/(auth)/join/[code]/page.tsx
Normal file
39
src/app/(auth)/join/[code]/page.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { getInviteByCode } from "@/app/actions/invites"
|
||||||
|
import { JoinForm } from "@/components/auth/join-form"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ code: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function JoinPage({ params }: Props) {
|
||||||
|
const { code } = await params
|
||||||
|
|
||||||
|
const result = await getInviteByCode(code)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Invalid invite
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This invite link is invalid or has expired.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinForm
|
||||||
|
code={code}
|
||||||
|
orgName={result.data.organizationName}
|
||||||
|
role={result.data.role}
|
||||||
|
isAuthenticated={currentUser !== null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
359
src/app/actions/invites.ts
Normal file
359
src/app/actions/invites.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import {
|
||||||
|
organizationInvites,
|
||||||
|
organizationMembers,
|
||||||
|
organizations,
|
||||||
|
users,
|
||||||
|
type NewOrganizationInvite,
|
||||||
|
} from "@/db/schema"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { requirePermission } from "@/lib/permissions"
|
||||||
|
import { isDemoUser } from "@/lib/demo"
|
||||||
|
import { eq, and, desc } from "drizzle-orm"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
// unambiguous charset — no 0/O/1/I/l
|
||||||
|
const CODE_CHARS = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||||
|
|
||||||
|
function generateInviteCode(orgSlug: string): string {
|
||||||
|
const prefix = orgSlug.replace(/[^a-z0-9]/g, "").slice(0, 3)
|
||||||
|
const suffix = Array.from(
|
||||||
|
{ length: 6 },
|
||||||
|
() => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]
|
||||||
|
).join("")
|
||||||
|
return `${prefix}-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- createInvite ---
|
||||||
|
|
||||||
|
export async function createInvite(
|
||||||
|
role: string,
|
||||||
|
maxUses?: number,
|
||||||
|
expiresAt?: string
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: { code: string; url: string } }> {
|
||||||
|
try {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||||
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||||
|
requirePermission(currentUser, "organization", "create")
|
||||||
|
|
||||||
|
if (!currentUser.organizationId) {
|
||||||
|
return { success: false, error: "No active organization" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const org = await db
|
||||||
|
.select({ slug: organizations.slug })
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.id, currentUser.organizationId))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!org) return { success: false, error: "Organization not found" }
|
||||||
|
|
||||||
|
const code = generateInviteCode(org.slug)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const invite: NewOrganizationInvite = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: currentUser.organizationId,
|
||||||
|
code,
|
||||||
|
role,
|
||||||
|
maxUses: maxUses ?? null,
|
||||||
|
useCount: 0,
|
||||||
|
expiresAt: expiresAt ?? null,
|
||||||
|
createdBy: currentUser.id,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(organizationInvites).values(invite).run()
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/settings")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
url: `/join/${code}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating invite:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- getOrgInvites ---
|
||||||
|
|
||||||
|
export async function getOrgInvites(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
data?: ReadonlyArray<{
|
||||||
|
readonly id: string
|
||||||
|
readonly code: string
|
||||||
|
readonly role: string
|
||||||
|
readonly maxUses: number | null
|
||||||
|
readonly useCount: number
|
||||||
|
readonly expiresAt: string | null
|
||||||
|
readonly isActive: boolean
|
||||||
|
readonly createdAt: string
|
||||||
|
readonly createdByName: string | null
|
||||||
|
}>
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||||
|
requirePermission(currentUser, "organization", "read")
|
||||||
|
|
||||||
|
if (!currentUser.organizationId) {
|
||||||
|
return { success: false, error: "No active organization" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const invites = await db
|
||||||
|
.select({
|
||||||
|
id: organizationInvites.id,
|
||||||
|
code: organizationInvites.code,
|
||||||
|
role: organizationInvites.role,
|
||||||
|
maxUses: organizationInvites.maxUses,
|
||||||
|
useCount: organizationInvites.useCount,
|
||||||
|
expiresAt: organizationInvites.expiresAt,
|
||||||
|
isActive: organizationInvites.isActive,
|
||||||
|
createdAt: organizationInvites.createdAt,
|
||||||
|
createdByName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(organizationInvites)
|
||||||
|
.leftJoin(users, eq(organizationInvites.createdBy, users.id))
|
||||||
|
.where(eq(organizationInvites.organizationId, currentUser.organizationId))
|
||||||
|
.orderBy(desc(organizationInvites.createdAt))
|
||||||
|
|
||||||
|
return { success: true, data: invites }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching org invites:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- revokeInvite ---
|
||||||
|
|
||||||
|
export async function revokeInvite(
|
||||||
|
inviteId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||||
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||||
|
requirePermission(currentUser, "organization", "update")
|
||||||
|
|
||||||
|
if (!currentUser.organizationId) {
|
||||||
|
return { success: false, error: "No active organization" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
// verify invite belongs to this org before revoking
|
||||||
|
const invite = await db
|
||||||
|
.select({ id: organizationInvites.id })
|
||||||
|
.from(organizationInvites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(organizationInvites.id, inviteId),
|
||||||
|
eq(organizationInvites.organizationId, currentUser.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!invite) return { success: false, error: "Invite not found" }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(organizationInvites)
|
||||||
|
.set({ isActive: false })
|
||||||
|
.where(eq(organizationInvites.id, inviteId))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/settings")
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking invite:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- getInviteByCode (public — no auth) ---
|
||||||
|
|
||||||
|
export async function getInviteByCode(code: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
data?: {
|
||||||
|
readonly organizationName: string
|
||||||
|
readonly role: string
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
const INVALID = "This invite link is invalid or has expired"
|
||||||
|
try {
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return { success: false, error: INVALID }
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.select({
|
||||||
|
id: organizationInvites.id,
|
||||||
|
role: organizationInvites.role,
|
||||||
|
maxUses: organizationInvites.maxUses,
|
||||||
|
useCount: organizationInvites.useCount,
|
||||||
|
expiresAt: organizationInvites.expiresAt,
|
||||||
|
isActive: organizationInvites.isActive,
|
||||||
|
organizationName: organizations.name,
|
||||||
|
})
|
||||||
|
.from(organizationInvites)
|
||||||
|
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
||||||
|
.where(eq(organizationInvites.code, code))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row || !row.isActive) return { success: false, error: INVALID }
|
||||||
|
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
||||||
|
return { success: false, error: INVALID }
|
||||||
|
}
|
||||||
|
if (row.maxUses !== null && row.useCount >= row.maxUses) {
|
||||||
|
return { success: false, error: INVALID }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: { organizationName: row.organizationName, role: row.role } }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error looking up invite:", error)
|
||||||
|
return { success: false, error: INVALID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- acceptInvite ---
|
||||||
|
|
||||||
|
export async function acceptInvite(code: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
data?: { organizationId: string; organizationName: string }
|
||||||
|
}> {
|
||||||
|
const INVALID = "This invite link is invalid or has expired"
|
||||||
|
try {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||||
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const invite = await db
|
||||||
|
.select({
|
||||||
|
id: organizationInvites.id,
|
||||||
|
organizationId: organizationInvites.organizationId,
|
||||||
|
role: organizationInvites.role,
|
||||||
|
maxUses: organizationInvites.maxUses,
|
||||||
|
useCount: organizationInvites.useCount,
|
||||||
|
expiresAt: organizationInvites.expiresAt,
|
||||||
|
isActive: organizationInvites.isActive,
|
||||||
|
organizationName: organizations.name,
|
||||||
|
})
|
||||||
|
.from(organizationInvites)
|
||||||
|
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
||||||
|
.where(eq(organizationInvites.code, code))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!invite || !invite.isActive) return { success: false, error: INVALID }
|
||||||
|
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
||||||
|
return { success: false, error: INVALID }
|
||||||
|
}
|
||||||
|
if (invite.maxUses !== null && invite.useCount >= invite.maxUses) {
|
||||||
|
return { success: false, error: INVALID }
|
||||||
|
}
|
||||||
|
|
||||||
|
// check user is not already a member
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: organizationMembers.id })
|
||||||
|
.from(organizationMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(organizationMembers.organizationId, invite.organizationId),
|
||||||
|
eq(organizationMembers.userId, currentUser.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: "You are already a member of this organization" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(organizationMembers)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: invite.organizationId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
role: invite.role,
|
||||||
|
joinedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const newUseCount = invite.useCount + 1
|
||||||
|
const exhausted = invite.maxUses !== null && newUseCount >= invite.maxUses
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(organizationInvites)
|
||||||
|
.set({
|
||||||
|
useCount: newUseCount,
|
||||||
|
...(exhausted ? { isActive: false } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(organizationInvites.id, invite.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.set("compass-active-org", invite.organizationId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
organizationId: invite.organizationId,
|
||||||
|
organizationName: invite.organizationName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error accepting invite:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/components/auth/join-form.tsx
Normal file
74
src/components/auth/join-form.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { IconLoader } from "@tabler/icons-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { acceptInvite } from "@/app/actions/invites"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string
|
||||||
|
orgName: string
|
||||||
|
role: string
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinForm({ code, orgName, role, isAuthenticated }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isPending, setIsPending] = React.useState(false)
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
const result = await acceptInvite(code)
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Failed to join organization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push("/dashboard")
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignIn() {
|
||||||
|
router.push(`/login?from=/join/${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
You've been invited
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Join <span className="font-medium text-foreground">{orgName}</span> as{" "}
|
||||||
|
<span className="font-medium text-foreground capitalize">{role}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending && (
|
||||||
|
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Join {orgName}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button className="w-full" onClick={handleSignIn}>
|
||||||
|
Sign in to join
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
You'll be redirected back after signing in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
src/components/settings/create-invite-dialog.tsx
Normal file
192
src/components/settings/create-invite-dialog.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { IconCopy } from "@tabler/icons-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createInvite } from "@/app/actions/invites"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
|
const EXPIRY_PRESETS = [
|
||||||
|
{ label: "Never", value: "never" },
|
||||||
|
{ label: "1 hour", value: "1h" },
|
||||||
|
{ label: "1 day", value: "1d" },
|
||||||
|
{ label: "7 days", value: "7d" },
|
||||||
|
{ label: "30 days", value: "30d" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function getExpiryDate(preset: string): string | undefined {
|
||||||
|
const now = Date.now()
|
||||||
|
switch (preset) {
|
||||||
|
case "1h":
|
||||||
|
return new Date(now + 60 * 60 * 1000).toISOString()
|
||||||
|
case "1d":
|
||||||
|
return new Date(now + 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
case "7d":
|
||||||
|
return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
case "30d":
|
||||||
|
return new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateInviteDialogProps {
|
||||||
|
readonly open: boolean
|
||||||
|
readonly onOpenChange: (open: boolean) => void
|
||||||
|
readonly onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateInviteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCreated,
|
||||||
|
}: CreateInviteDialogProps) {
|
||||||
|
const [role, setRole] = React.useState("office")
|
||||||
|
const [maxUses, setMaxUses] = React.useState("")
|
||||||
|
const [expiry, setExpiry] = React.useState("never")
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [createdUrl, setCreatedUrl] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await createInvite(
|
||||||
|
role,
|
||||||
|
maxUses ? parseInt(maxUses, 10) : undefined,
|
||||||
|
getExpiryDate(expiry)
|
||||||
|
)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const fullUrl = `${window.location.origin}${result.data.url}`
|
||||||
|
setCreatedUrl(fullUrl)
|
||||||
|
onCreated()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Failed to create invite")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (createdUrl) {
|
||||||
|
navigator.clipboard.writeText(createdUrl)
|
||||||
|
toast.success("Invite link copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setCreatedUrl(null)
|
||||||
|
setRole("office")
|
||||||
|
setMaxUses("")
|
||||||
|
setExpiry("never")
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{createdUrl ? "Invite Link Created" : "Create Invite Link"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{createdUrl
|
||||||
|
? "Share this link with people you want to invite."
|
||||||
|
: "Create a shareable link for your organization."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{createdUrl ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={createdUrl}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="outline" onClick={handleCopy}>
|
||||||
|
<IconCopy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select value={role} onValueChange={setRole}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="office">Office</SelectItem>
|
||||||
|
<SelectItem value="field">Field</SelectItem>
|
||||||
|
<SelectItem value="client">Client</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max uses (optional)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(e) => setMaxUses(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Expires</Label>
|
||||||
|
<Select value={expiry} onValueChange={setExpiry}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXPIRY_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value}>
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{createdUrl ? (
|
||||||
|
<Button onClick={() => handleClose(false)}>Done</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
|
{loading ? "Creating..." : "Create Link"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
src/components/settings/invite-links-section.tsx
Normal file
207
src/components/settings/invite-links-section.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { IconCopy, IconTrash, IconPlus } from "@tabler/icons-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { getOrgInvites, revokeInvite } from "@/app/actions/invites"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CreateInviteDialog } from "./create-invite-dialog"
|
||||||
|
|
||||||
|
type InviteRow = {
|
||||||
|
readonly id: string
|
||||||
|
readonly code: string
|
||||||
|
readonly role: string
|
||||||
|
readonly maxUses: number | null
|
||||||
|
readonly useCount: number
|
||||||
|
readonly expiresAt: string | null
|
||||||
|
readonly isActive: boolean
|
||||||
|
readonly createdAt: string
|
||||||
|
readonly createdByName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(expiresAt: string | null): boolean {
|
||||||
|
if (!expiresAt) return false
|
||||||
|
return new Date(expiresAt) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExhausted(invite: InviteRow): boolean {
|
||||||
|
return invite.maxUses !== null && invite.useCount >= invite.maxUses
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiry(expiresAt: string | null): string {
|
||||||
|
if (!expiresAt) return "Never"
|
||||||
|
const date = new Date(expiresAt)
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteLinksSection() {
|
||||||
|
const [invites, setInvites] = React.useState<InviteRow[]>([])
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const loadInvites = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getOrgInvites()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setInvites(result.data as InviteRow[])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load invites:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadInvites()
|
||||||
|
}, [loadInvites])
|
||||||
|
|
||||||
|
const handleCopyLink = (code: string) => {
|
||||||
|
const url = `${window.location.origin}/join/${code}`
|
||||||
|
navigator.clipboard.writeText(url)
|
||||||
|
toast.success("Invite link copied")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (inviteId: string) => {
|
||||||
|
const result = await revokeInvite(inviteId)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Invite revoked")
|
||||||
|
await loadInvites()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Failed to revoke invite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreated = () => {
|
||||||
|
loadInvites()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Invite Links</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Shareable links that let anyone join your organization
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<IconPlus className="mr-2 size-4" />
|
||||||
|
Create Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No invite links yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Uses</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created by</TableHead>
|
||||||
|
<TableHead className="w-[100px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invites.map((invite) => {
|
||||||
|
const expired = isExpired(invite.expiresAt)
|
||||||
|
const exhausted = isExhausted(invite)
|
||||||
|
const dimmed = !invite.isActive || expired || exhausted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={invite.id}
|
||||||
|
className={cn(dimmed && "opacity-50")}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{invite.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{invite.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{invite.useCount} / {invite.maxUses ?? "∞"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{expired ? (
|
||||||
|
<span className="text-destructive">Expired</span>
|
||||||
|
) : (
|
||||||
|
formatExpiry(invite.expiresAt)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{invite.createdByName ?? "Unknown"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!dimmed && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={() => handleCopyLink(invite.code)}
|
||||||
|
>
|
||||||
|
<IconCopy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invite.isActive && !expired && !exhausted && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-destructive"
|
||||||
|
onClick={() => handleRevoke(invite.id)}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateInviteDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,9 +6,11 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users"
|
import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { PeopleTable } from "@/components/people-table"
|
import { PeopleTable } from "@/components/people-table"
|
||||||
import { UserDrawer } from "@/components/people/user-drawer"
|
import { UserDrawer } from "@/components/people/user-drawer"
|
||||||
import { InviteDialog } from "@/components/people/invite-dialog"
|
import { InviteDialog } from "@/components/people/invite-dialog"
|
||||||
|
import { InviteLinksSection } from "@/components/settings/invite-links-section"
|
||||||
|
|
||||||
export function TeamTab() {
|
export function TeamTab() {
|
||||||
const [users, setUsers] = React.useState<UserWithRelations[]>([])
|
const [users, setUsers] = React.useState<UserWithRelations[]>([])
|
||||||
@ -101,6 +103,9 @@ export function TeamTab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<InviteLinksSection />
|
||||||
|
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
|
|||||||
@ -44,6 +44,23 @@ export const organizationMembers = sqliteTable("organization_members", {
|
|||||||
joinedAt: text("joined_at").notNull(),
|
joinedAt: text("joined_at").notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const organizationInvites = sqliteTable("organization_invites", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id, { onDelete: "cascade" }),
|
||||||
|
code: text("code").notNull().unique(),
|
||||||
|
role: text("role").notNull().default("office"),
|
||||||
|
maxUses: integer("max_uses"),
|
||||||
|
useCount: integer("use_count").notNull().default(0),
|
||||||
|
expiresAt: text("expires_at"),
|
||||||
|
createdBy: text("created_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export const teams = sqliteTable("teams", {
|
export const teams = sqliteTable("teams", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
@ -238,6 +255,8 @@ export type Organization = typeof organizations.$inferSelect
|
|||||||
export type NewOrganization = typeof organizations.$inferInsert
|
export type NewOrganization = typeof organizations.$inferInsert
|
||||||
export type OrganizationMember = typeof organizationMembers.$inferSelect
|
export type OrganizationMember = typeof organizationMembers.$inferSelect
|
||||||
export type NewOrganizationMember = typeof organizationMembers.$inferInsert
|
export type NewOrganizationMember = typeof organizationMembers.$inferInsert
|
||||||
|
export type OrganizationInvite = typeof organizationInvites.$inferSelect
|
||||||
|
export type NewOrganizationInvite = typeof organizationInvites.$inferInsert
|
||||||
export type Team = typeof teams.$inferSelect
|
export type Team = typeof teams.$inferSelect
|
||||||
export type NewTeam = typeof teams.$inferInsert
|
export type NewTeam = typeof teams.$inferInsert
|
||||||
export type TeamMember = typeof teamMembers.$inferSelect
|
export type TeamMember = typeof teamMembers.$inferSelect
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export * from "./teams"
|
|||||||
export * from "./schedule"
|
export * from "./schedule"
|
||||||
export * from "./financial"
|
export * from "./financial"
|
||||||
export * from "./profile"
|
export * from "./profile"
|
||||||
|
export * from "./invites"
|
||||||
|
|||||||
32
src/lib/validations/invites.ts
Normal file
32
src/lib/validations/invites.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { uuidSchema } from "./common"
|
||||||
|
|
||||||
|
const inviteCodeRegex = /^[a-z0-9]+-[23456789abcdefghjkmnpqrstuvwxyz]{6}$/
|
||||||
|
|
||||||
|
const orgRoles = ["admin", "office", "field", "client"] as const
|
||||||
|
|
||||||
|
export const createInviteSchema = z.object({
|
||||||
|
role: z.enum(orgRoles, { message: "Please select a valid role" }),
|
||||||
|
maxUses: z.number().int().positive().optional(),
|
||||||
|
expiresAt: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(val) => !Number.isNaN(Date.parse(val)),
|
||||||
|
"Please enter a valid date"
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateInviteInput = z.infer<typeof createInviteSchema>
|
||||||
|
|
||||||
|
export const revokeInviteSchema = z.object({
|
||||||
|
inviteId: uuidSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RevokeInviteInput = z.infer<typeof revokeInviteSchema>
|
||||||
|
|
||||||
|
export const acceptInviteSchema = z.object({
|
||||||
|
code: z.string().regex(inviteCodeRegex, "Invalid invite code format"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AcceptInviteInput = z.infer<typeof acceptInviteSchema>
|
||||||
@ -23,6 +23,7 @@ const bridgePaths = [
|
|||||||
function isPublicPath(pathname: string): boolean {
|
function isPublicPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
publicPaths.includes(pathname) ||
|
publicPaths.includes(pathname) ||
|
||||||
|
pathname.startsWith("/join/") ||
|
||||||
bridgePaths.includes(pathname) ||
|
bridgePaths.includes(pathname) ||
|
||||||
pathname.startsWith("/api/auth/") ||
|
pathname.startsWith("/api/auth/") ||
|
||||||
pathname.startsWith("/api/netsuite/") ||
|
pathname.startsWith("/api/netsuite/") ||
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user