diff --git a/apps/api-server/src/procedures/me/_routes.ts b/apps/api-server/src/procedures/me/_routes.ts new file mode 100644 index 0000000..7a1c531 --- /dev/null +++ b/apps/api-server/src/procedures/me/_routes.ts @@ -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, +}; diff --git a/apps/api-server/src/procedures/me/auth-status.ts b/apps/api-server/src/procedures/me/auth-status.ts new file mode 100644 index 0000000..6bbf857 --- /dev/null +++ b/apps/api-server/src/procedures/me/auth-status.ts @@ -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, + }; + }); diff --git a/apps/api-server/src/procedures/me/get.ts b/apps/api-server/src/procedures/me/get.ts new file mode 100644 index 0000000..ecd705e --- /dev/null +++ b/apps/api-server/src/procedures/me/get.ts @@ -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, + }; + }); diff --git a/apps/api-server/src/procedures/me/index.ts b/apps/api-server/src/procedures/me/index.ts index 4c993c8..2592fb3 100644 --- a/apps/api-server/src/procedures/me/index.ts +++ b/apps/api-server/src/procedures/me/index.ts @@ -10,6 +10,12 @@ export { trustDevice, untrustDevice, } from "./devices.js"; +export { + acceptInvite, + declineInvite, + getInvite, + listInvites, +} from "./invites.js"; export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; export { listSessions, diff --git a/apps/api-server/src/procedures/me/invites.ts b/apps/api-server/src/procedures/me/invites.ts new file mode 100644 index 0000000..0176f96 --- /dev/null +++ b/apps/api-server/src/procedures/me/invites.ts @@ -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 }; + }); diff --git a/apps/api-server/src/procedures/me/setup-profile.ts b/apps/api-server/src/procedures/me/setup-profile.ts new file mode 100644 index 0000000..da083ff --- /dev/null +++ b/apps/api-server/src/procedures/me/setup-profile.ts @@ -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 }; + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index e4de764..839df1b 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -15,26 +15,7 @@ import { loginRequestMiddleware, os, } from "./procedures/base.js"; -import { meDelete } from "./procedures/me/delete.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 { meRoutes } from "./procedures/me/_routes.js"; import { invitesAccept, invitesCancel, @@ -164,105 +145,6 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication 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 export const router = os.router({ auth: { @@ -283,27 +165,7 @@ export const router = os.router({ verifyAuthentication, }, }, - me: { - get: meGet, - authStatus: meAuthStatus, - setupProfile, - updateProfile, - delete: meDelete, - setPassword, - passkeys: { - list: listPasskeys, - rename: renamePasskey, - delete: deletePasskey, - }, - listSessions, - revokeSession, - revokeAllSessions, - getDeviceInfo, - trustDevice, - listTrustedDevices, - untrustDevice, - revokeAllTrustedDevices, - }, + me: meRoutes, orgs: { list: orgsList, create: orgsCreate, diff --git a/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte b/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte new file mode 100644 index 0000000..8d87bc0 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte @@ -0,0 +1,243 @@ + + + + Organization Invitation | Publisher Dashboard + + +
+ + + + {#if inviteQuery.isPending} +
+ +

Loading invitation...

+
+ {:else if inviteQuery.error} + + + + {inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"} + + + + {:else if inviteQuery.data} + {@const invite = inviteQuery.data} + + +
+ {#if invite.org.logoUrl} + {invite.org.displayName} logo + {:else} +
+ +
+ {/if} +
+ {invite.org.displayName} + + You've been invited to join this organization + +
+
+
+ + +
+
+
+ +
+
+

Role

+

{formatRole(invite.role)}

+
+
+
+
+ +
+
+

Invited by

+

{invite.invitedBy}

+
+
+
+
+ +
+
+

Sent on

+

{formatDate(new Date(invite.createdAt))}

+
+
+
+
+ +
+
+

Expires on

+

+ {formatDate(new Date(invite.expiresAt))} +

+
+
+
+ + {#if isExpiringSoon(new Date(invite.expiresAt))} + + + + This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization. + + + {/if} + + + + +
+ + acceptMutation.mutate()} + > + + Accept & Join + +
+
+
+ {/if} +
diff --git a/apps/publisher-dashboard/src/routes/dashboard/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte index ee36f22..0ae4aa4 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte @@ -1,18 +1,27 @@ @@ -64,7 +86,61 @@ function formatDate(date: Date): string { -
+
+ + {#if invitesQuery.data && invitesQuery.data.length > 0} + + {/if} + + {#if orgsQuery.isPending}
@@ -79,8 +155,8 @@ function formatDate(date: Date): string { {orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}

- {:else if orgsQuery.data && orgsQuery.data.length === 0} - + {:else if orgsQuery.data && orgsQuery.data.length === 0 && (!invitesQuery.data || invitesQuery.data.length === 0)} +
@@ -93,47 +169,57 @@ function formatDate(date: Date): string {

+ {:else if orgsQuery.data && orgsQuery.data.length === 0 && invitesQuery.data && invitesQuery.data.length > 0} + +
+ Accept an invitation above to join an organization. +
{:else if orgsQuery.data} -
- {#each orgsQuery.data as org (org.id)} - - - -
- - {#if org.logoUrl} - {org.displayName} logo - {:else} - diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 59ed053..ebf251f 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -45,6 +45,7 @@ import { setupProfileInputSchema, trustDeviceInputSchema, updateProfileInputSchema, + userInviteOutputSchema, userProfileSchema, } from "./schemas/user.js"; @@ -147,6 +148,20 @@ export const contract = oc.router({ .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 listSessions: oc.output(z.array(sessionOutputSchema)), revokeSession: oc diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts index 7efd4c1..cd45242 100644 --- a/packages/api-contract/src/schemas/user.ts +++ b/packages/api-contract/src/schemas/user.ts @@ -1,5 +1,6 @@ import * as z from "zod"; import { nonEmptyString, optionalString, phoneSchema } from "./common.js"; +import { orgRoleSchema } from "./org.js"; /** * User profile schema @@ -132,3 +133,21 @@ export const authStatusOutputSchema = z.object({ 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(), +});