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:
53
apps/api-server/src/procedures/me/_routes.ts
Normal file
53
apps/api-server/src/procedures/me/_routes.ts
Normal 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,
|
||||
};
|
||||
41
apps/api-server/src/procedures/me/auth-status.ts
Normal file
41
apps/api-server/src/procedures/me/auth-status.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
38
apps/api-server/src/procedures/me/get.ts
Normal file
38
apps/api-server/src/procedures/me/get.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
211
apps/api-server/src/procedures/me/invites.ts
Normal file
211
apps/api-server/src/procedures/me/invites.ts
Normal 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 };
|
||||
});
|
||||
24
apps/api-server/src/procedures/me/setup-profile.ts
Normal file
24
apps/api-server/src/procedures/me/setup-profile.ts
Normal 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 };
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user