Add org invites section to dashboard with accept/decline flow

Backend:
- Add me.invites endpoints (list, get, accept, decline) to API contract
- Create invites procedures for fetching user's pending invites
- Only show invites if email matches and is verified
- Refactor me routes into me/_routes.ts for consistency

Frontend:
- Add pending invitations section to /dashboard page
- Create /account/org-invites/[inviteId] page for accept/decline
- Show invite details (org, role, inviter, dates)
- Redirect to org dashboard after accepting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-10 17:11:22 +08:00
parent 9f4c6ac0b9
commit 39863bd947
11 changed files with 779 additions and 181 deletions

View File

@@ -0,0 +1,53 @@
/**
* Me routes - consolidated exports for os.router()
*/
import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js";
import {
getDeviceInfo,
listTrustedDevices,
revokeAllTrustedDevices,
trustDevice,
untrustDevice,
} from "./devices.js";
import { meGet } from "./get.js";
import {
acceptInvite,
declineInvite,
getInvite,
listInvites,
} from "./invites.js";
import { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js";
import { listSessions, revokeAllSessions, revokeSession } from "./sessions.js";
import { setPassword } from "./set-password.js";
import { setupProfile } from "./setup-profile.js";
import { updateProfile } from "./update-profile.js";
export const meRoutes = {
get: meGet,
authStatus: meAuthStatus,
setupProfile,
updateProfile,
delete: meDelete,
setPassword,
passkeys: {
list: listPasskeys,
rename: renamePasskey,
delete: deletePasskey,
},
invites: {
list: listInvites,
get: getInvite,
accept: acceptInvite,
decline: declineInvite,
},
listSessions,
revokeSession,
revokeAllSessions,
getDeviceInfo,
trustDevice,
listTrustedDevices,
untrustDevice,
revokeAllTrustedDevices,
};

View File

@@ -0,0 +1,41 @@
/**
* Get current user auth status
*/
import { authMiddleware, os } from "../base.js";
export const meAuthStatus = os.me.authStatus
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
},
auth: context.auth,
};
});

View File

@@ -0,0 +1,38 @@
/**
* Get current user profile
*/
import { authMiddleware, os } from "../base.js";
export const meGet = os.me.get
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
};
});

View File

@@ -10,6 +10,12 @@ export {
trustDevice, trustDevice,
untrustDevice, untrustDevice,
} from "./devices.js"; } from "./devices.js";
export {
acceptInvite,
declineInvite,
getInvite,
listInvites,
} from "./invites.js";
export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js";
export { export {
listSessions, listSessions,

View File

@@ -0,0 +1,211 @@
/**
* User invite procedures - list, get, decline invites for the current user
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
/**
* List pending invites for the current user
* Only returns invites where the user's email matches and email is verified
*/
export const listInvites = os.me.invites.list
.use(authMiddleware)
.handler(async ({ context }) => {
// Only show invites if email is verified
if (!context.user.emailVerifiedAt) {
return [];
}
// Get non-expired invites matching user's email
const invites = await context.db
.selectFrom("org_invites")
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
.innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.email", "=", context.user.email.toLowerCase())
.where("org_invites.expires_at", ">", new Date())
.select([
"org_invites.id",
"org_invites.role",
"org_invites.created_at",
"org_invites.expires_at",
"orgs.id as org_id",
"orgs.slug as org_slug",
"orgs.display_name as org_display_name",
"orgs.logo_url as org_logo_url",
"users.display_name as inviter_name",
"users.email as inviter_email",
])
.orderBy("org_invites.created_at", "desc")
.execute();
return invites.map((i) => ({
id: i.id,
org: {
id: i.org_id,
slug: i.org_slug,
displayName: i.org_display_name,
logoUrl: i.org_logo_url,
},
role: i.role,
invitedBy: i.inviter_name ?? i.inviter_email,
createdAt: i.created_at,
expiresAt: i.expires_at,
}));
});
/**
* Get a specific invite by ID
* Only returns if the invite belongs to the current user's email
*/
export const getInvite = os.me.invites.get
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Only show invite if email is verified
if (!context.user.emailVerifiedAt) {
throw new ORPCError("FORBIDDEN", {
message: "Please verify your email to view invitations",
});
}
// Get the invite matching user's email
const invite = await context.db
.selectFrom("org_invites")
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
.innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.id", "=", inviteId)
.where("org_invites.email", "=", context.user.email.toLowerCase())
.where("org_invites.expires_at", ">", new Date())
.select([
"org_invites.id",
"org_invites.role",
"org_invites.created_at",
"org_invites.expires_at",
"orgs.id as org_id",
"orgs.slug as org_slug",
"orgs.display_name as org_display_name",
"orgs.logo_url as org_logo_url",
"users.display_name as inviter_name",
"users.email as inviter_email",
])
.executeTakeFirst();
if (!invite) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found or expired",
});
}
return {
id: invite.id,
org: {
id: invite.org_id,
slug: invite.org_slug,
displayName: invite.org_display_name,
logoUrl: invite.org_logo_url,
},
role: invite.role,
invitedBy: invite.inviter_name ?? invite.inviter_email,
createdAt: invite.created_at,
expiresAt: invite.expires_at,
};
});
/**
* Accept an invite by ID
* Adds user to org and deletes the invite
*/
export const acceptInvite = os.me.invites.accept
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Only allow accepting if email is verified
if (!context.user.emailVerifiedAt) {
throw new ORPCError("FORBIDDEN", {
message: "Please verify your email to accept invitations",
});
}
// Get the invite matching user's email
const invite = await context.db
.selectFrom("org_invites")
.where("id", "=", inviteId)
.where("email", "=", context.user.email.toLowerCase())
.where("expires_at", ">", new Date())
.select(["id", "org_id", "role"])
.executeTakeFirst();
if (!invite) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found or expired",
});
}
try {
// Accept the invite in a transaction
await context.db.transaction().execute(async (trx) => {
// Add user as a member
await trx
.insertInto("org_members")
.values({
org_id: invite.org_id,
user_id: context.user.id,
role: invite.role,
})
.execute();
// Delete the invite
await trx
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
});
} catch (error) {
// Handle unique constraint violation (user is already a member)
if (
error instanceof Error &&
error.message.includes("org_members_org_id_user_id_key")
) {
// Clean up the invite since user is already a member
await context.db
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
throw new ORPCError("CONFLICT", {
message: "You are already a member of this organization",
});
}
throw error;
}
return { success: true };
});
/**
* Decline an invite
* Deletes the invite if it belongs to the current user's email
*/
export const declineInvite = os.me.invites.decline
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Delete the invite only if it matches user's email
const result = await context.db
.deleteFrom("org_invites")
.where("id", "=", inviteId)
.where("email", "=", context.user.email.toLowerCase())
.executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found",
});
}
return { success: true };
});

View File

@@ -0,0 +1,24 @@
/**
* Setup user profile (initial setup after signup)
*/
import { authMiddleware, os } from "../base.js";
export const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { displayName, fullName, phoneNumber } = input;
await context.db
.updateTable("users")
.set({
display_name: displayName,
full_name: fullName ?? null,
phone_number: phoneNumber ?? null,
updated_at: new Date(),
})
.where("id", "=", context.user.id)
.execute();
return { success: true };
});

View File

@@ -15,26 +15,7 @@ import {
loginRequestMiddleware, loginRequestMiddleware,
os, os,
} from "./procedures/base.js"; } from "./procedures/base.js";
import { meDelete } from "./procedures/me/delete.js"; import { meRoutes } from "./procedures/me/_routes.js";
import {
getDeviceInfo,
listTrustedDevices,
revokeAllTrustedDevices,
trustDevice,
untrustDevice,
} from "./procedures/me/devices.js";
import {
deletePasskey,
listPasskeys,
renamePasskey,
} from "./procedures/me/passkeys.js";
import {
listSessions,
revokeAllSessions,
revokeSession,
} from "./procedures/me/sessions.js";
import { setPassword } from "./procedures/me/set-password.js";
import { updateProfile } from "./procedures/me/update-profile.js";
import { import {
invitesAccept, invitesAccept,
invitesCancel, invitesCancel,
@@ -164,105 +145,6 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
return { success: true }; return { success: true };
}); });
// Me procedures
const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
};
});
const meAuthStatus = os.me.authStatus
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
},
auth: context.auth,
};
});
const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { displayName, fullName, phoneNumber } = input;
await context.db
.updateTable("users")
.set({
display_name: displayName,
full_name: fullName ?? null,
phone_number: phoneNumber ?? null,
updated_at: new Date(),
})
.where("id", "=", context.user.id)
.execute();
return { success: true };
});
// Me procedures imported from ./procedures/me/*
// - updateProfile, setPassword, meDelete
// - listPasskeys, renamePasskey, deletePasskey
// - listSessions, revokeSession, revokeAllSessions
// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices
// Orgs procedures - imported from ./procedures/orgs/index.js
// - orgsList, orgsCreate, orgsGet, orgsUpdate, orgsDelete, orgsLeave
// - membersList, membersUpdateRole, membersRemove
// - invitesList, invitesCreate, invitesCancel, invitesAccept
// - sitesList
// Build the router // Build the router
export const router = os.router({ export const router = os.router({
auth: { auth: {
@@ -283,27 +165,7 @@ export const router = os.router({
verifyAuthentication, verifyAuthentication,
}, },
}, },
me: { me: meRoutes,
get: meGet,
authStatus: meAuthStatus,
setupProfile,
updateProfile,
delete: meDelete,
setPassword,
passkeys: {
list: listPasskeys,
rename: renamePasskey,
delete: deletePasskey,
},
listSessions,
revokeSession,
revokeAllSessions,
getDeviceInfo,
trustDevice,
listTrustedDevices,
untrustDevice,
revokeAllTrustedDevices,
},
orgs: { orgs: {
list: orgsList, list: orgsList,
create: orgsCreate, create: orgsCreate,

View File

@@ -0,0 +1,243 @@
<script lang="ts">
import {
AlertCircle,
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Clock,
Loader2,
User,
XCircle,
} from "@lucide/svelte";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator";
const inviteId = $derived(Number(page.params.inviteId));
const queryClient = useQueryClient();
// Fetch the invite details
const inviteQuery = createQuery(() => ({
queryKey: ["me", "invites", inviteId],
queryFn: () => api.me.invites.get({ inviteId }),
enabled: !Number.isNaN(inviteId),
}));
// Accept mutation
const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => {
toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) {
goto(`/dashboard/${inviteQuery.data.org.slug}`);
} else {
goto("/dashboard");
}
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to accept invitation",
);
},
}));
// Decline mutation
const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => {
toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
goto("/dashboard");
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to decline invitation",
);
},
}));
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
/**
* Check if invite is expiring soon (within 3 days)
*/
function isExpiringSoon(expiresAt: Date): boolean {
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
return expiresAt < threeDaysFromNow;
}
</script>
<svelte:head>
<title>Organization Invitation | Publisher Dashboard</title>
</svelte:head>
<div class="space-y-6">
<!-- Back link -->
<Button variant="ghost" size="sm" href="/dashboard" class="-ml-2">
<ArrowLeft class="mr-2 h-4 w-4" />
Back to Dashboard
</Button>
{#if inviteQuery.isPending}
<div class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
<p class="mt-4 text-sm text-muted-foreground">Loading invitation...</p>
</div>
{:else if inviteQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>
{inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"}
</AlertDescription>
</Alert>
<Button variant="outline" href="/dashboard">
Go to Dashboard
</Button>
{:else if inviteQuery.data}
{@const invite = inviteQuery.data}
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if invite.org.logoUrl}
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-16 w-16 rounded-xl object-cover"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-8 w-8 text-primary" />
</div>
{/if}
<div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1">
You've been invited to join this organization
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Invite details -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<User class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">Role</p>
<p class="text-sm text-muted-foreground">{formatRole(invite.role)}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<User class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">Invited by</p>
<p class="text-sm text-muted-foreground">{invite.invitedBy}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Calendar class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">Sent on</p>
<p class="text-sm text-muted-foreground">{formatDate(new Date(invite.createdAt))}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted {isExpiringSoon(new Date(invite.expiresAt)) ? 'bg-warning/10' : ''}">
<Clock class="h-5 w-5 {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}" />
</div>
<div>
<p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))}
</p>
</div>
</div>
</div>
{#if isExpiringSoon(new Date(invite.expiresAt))}
<Alert>
<Clock class="h-4 w-4" />
<AlertDescription>
This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization.
</AlertDescription>
</Alert>
{/if}
<Separator />
<!-- Actions -->
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button
variant="outline"
disabled={acceptMutation.isPending || declineMutation.isPending}
onclick={() => declineMutation.mutate()}
>
{#if declineMutation.isPending}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Declining...
{:else}
<XCircle class="mr-2 h-4 w-4" />
Decline
{/if}
</Button>
<LoadingButton
loading={acceptMutation.isPending}
disabled={declineMutation.isPending}
loadingText="Joining..."
onclick={() => acceptMutation.mutate()}
>
<CheckCircle2 class="mr-2 h-4 w-4" />
Accept & Join
</LoadingButton>
</div>
</CardContent>
</Card>
{/if}
</div>

View File

@@ -1,18 +1,27 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Building2, Loader2 } from "@lucide/svelte"; import {
AlertCircle,
Building2,
ChevronRight,
Loader2,
Mail,
} from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { Badge } from "$lib/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
/** /**
* Dashboard page - lists all organizations the user is a member of * Dashboard page - lists all organizations the user is a member of
* Also shows pending invites at the top
*/ */
// Fetch user's organizations // Fetch user's organizations
@@ -21,6 +30,12 @@ const orgsQuery = createQuery(() => ({
queryFn: () => api.orgs.list(), queryFn: () => api.orgs.list(),
})); }));
// Fetch user's pending invites
const invitesQuery = createQuery(() => ({
queryKey: ["me", "invites"],
queryFn: () => api.me.invites.list(),
}));
// Redirect to login on auth error // Redirect to login on auth error
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
@@ -57,6 +72,13 @@ function formatDate(date: Date): string {
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
}); });
} }
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -64,7 +86,61 @@ function formatDate(date: Date): string {
</svelte:head> </svelte:head>
<DashboardLayout title="Organizations"> <DashboardLayout title="Organizations">
<div class="space-y-6"> <div class="space-y-8">
<!-- Pending Invites Section -->
{#if invitesQuery.data && invitesQuery.data.length > 0}
<div class="space-y-4">
<div class="flex items-center gap-2">
<Mail class="h-5 w-5 text-primary" />
<h2 class="text-lg font-semibold">Pending Invitations</h2>
<Badge variant="secondary">{invitesQuery.data.length}</Badge>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each invitesQuery.data as invite (invite.id)}
<a
href="/account/org-invites/{invite.id}"
class="group block"
>
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3">
{#if invite.org.logoUrl}
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-10 w-10 rounded-lg object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
<Building2 class="h-5 w-5 text-primary" />
</div>
{/if}
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{invite.org.displayName}
</CardTitle>
<CardDescription class="truncate text-xs">
Invited as {formatRole(invite.role)}
</CardDescription>
</div>
</div>
<ChevronRight class="h-5 w-5 text-muted-foreground group-hover:text-primary" />
</div>
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))}
</p>
</CardContent>
</Card>
</a>
{/each}
</div>
</div>
{/if}
<!-- Organizations Section -->
{#if orgsQuery.isPending} {#if orgsQuery.isPending}
<!-- Loading state --> <!-- Loading state -->
<div class="flex flex-col items-center justify-center py-16"> <div class="flex flex-col items-center justify-center py-16">
@@ -79,8 +155,8 @@ function formatDate(date: Date): string {
{orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"} {orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}
</p> </p>
</div> </div>
{:else if orgsQuery.data && orgsQuery.data.length === 0} {:else if orgsQuery.data && orgsQuery.data.length === 0 && (!invitesQuery.data || invitesQuery.data.length === 0)}
<!-- Empty state --> <!-- Empty state (no orgs and no invites) -->
<Card class="border-dashed"> <Card class="border-dashed">
<CardContent class="flex flex-col items-center justify-center py-16"> <CardContent class="flex flex-col items-center justify-center py-16">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
@@ -93,8 +169,17 @@ function formatDate(date: Date): string {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{:else if orgsQuery.data && orgsQuery.data.length === 0 && invitesQuery.data && invitesQuery.data.length > 0}
<!-- No orgs but has invites -->
<div class="text-center text-sm text-muted-foreground py-4">
Accept an invitation above to join an organization.
</div>
{:else if orgsQuery.data} {:else if orgsQuery.data}
<!-- Org grid --> <!-- Org grid -->
<div class="space-y-4">
{#if invitesQuery.data && invitesQuery.data.length > 0}
<h2 class="text-lg font-semibold">Your Organizations</h2>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each orgsQuery.data as org (org.id)} {#each orgsQuery.data as org (org.id)}
<a <a
@@ -135,6 +220,7 @@ function formatDate(date: Date): string {
</a> </a>
{/each} {/each}
</div> </div>
</div>
{/if} {/if}
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@@ -45,6 +45,7 @@ import {
setupProfileInputSchema, setupProfileInputSchema,
trustDeviceInputSchema, trustDeviceInputSchema,
updateProfileInputSchema, updateProfileInputSchema,
userInviteOutputSchema,
userProfileSchema, userProfileSchema,
} from "./schemas/user.js"; } from "./schemas/user.js";
@@ -147,6 +148,20 @@ export const contract = oc.router({
.output(successResponseSchema), .output(successResponseSchema),
}), }),
// Org invites for the current user
invites: oc.router({
list: oc.output(z.array(userInviteOutputSchema)),
get: oc
.input(z.object({ inviteId: z.number() }))
.output(userInviteOutputSchema),
accept: oc
.input(z.object({ inviteId: z.number() }))
.output(successResponseSchema),
decline: oc
.input(z.object({ inviteId: z.number() }))
.output(successResponseSchema),
}),
// Sessions & devices // Sessions & devices
listSessions: oc.output(z.array(sessionOutputSchema)), listSessions: oc.output(z.array(sessionOutputSchema)),
revokeSession: oc revokeSession: oc

View File

@@ -1,5 +1,6 @@
import * as z from "zod"; import * as z from "zod";
import { nonEmptyString, optionalString, phoneSchema } from "./common.js"; import { nonEmptyString, optionalString, phoneSchema } from "./common.js";
import { orgRoleSchema } from "./org.js";
/** /**
* User profile schema * User profile schema
@@ -132,3 +133,21 @@ export const authStatusOutputSchema = z.object({
sessionAuthStatusSchema, sessionAuthStatusSchema,
]), ]),
}); });
/**
* User invite output schema
* Returned by me.invites.list - includes org info for the user's pending invites
*/
export const userInviteOutputSchema = z.object({
id: z.number(),
org: z.object({
id: z.number(),
slug: z.string(),
displayName: z.string(),
logoUrl: z.string().nullable(),
}),
role: orgRoleSchema,
invitedBy: z.string(),
createdAt: z.date(),
expiresAt: z.date(),
});