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 @@
+
+
+
Loading invitation...
+Role
+{formatRole(invite.role)}
+Invited by
+{invite.invitedBy}
+Sent on
+{formatDate(new Date(invite.createdAt))}
+Expires on
++ {formatDate(new Date(invite.expiresAt))} +
++ From {invite.invitedBy} · {formatDate(new Date(invite.createdAt))} +
++ {org.slug} +
- {org.slug} -
- Created {formatDate(new Date(org.createdAt))} -
-+ Created {formatDate(new Date(org.createdAt))} +
+