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,
|
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,
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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} · {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,47 +169,57 @@ 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="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="space-y-4">
|
||||||
{#each orgsQuery.data as org (org.id)}
|
{#if invitesQuery.data && invitesQuery.data.length > 0}
|
||||||
<a
|
<h2 class="text-lg font-semibold">Your Organizations</h2>
|
||||||
href="/dashboard/{org.slug}"
|
{/if}
|
||||||
class="group block transition-transform hover:scale-[1.02]"
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
>
|
{#each orgsQuery.data as org (org.id)}
|
||||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
<a
|
||||||
<CardHeader class="pb-3">
|
href="/dashboard/{org.slug}"
|
||||||
<div class="flex items-start gap-3">
|
class="group block transition-transform hover:scale-[1.02]"
|
||||||
<!-- Logo or placeholder -->
|
>
|
||||||
{#if org.logoUrl}
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
<img
|
<CardHeader class="pb-3">
|
||||||
src={org.logoUrl}
|
<div class="flex items-start gap-3">
|
||||||
alt="{org.displayName} logo"
|
<!-- Logo or placeholder -->
|
||||||
class="h-10 w-10 rounded-lg object-cover"
|
{#if org.logoUrl}
|
||||||
/>
|
<img
|
||||||
{:else}
|
src={org.logoUrl}
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
|
alt="{org.displayName} logo"
|
||||||
<Building2 class="h-5 w-5 text-primary" />
|
class="h-10 w-10 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
|
||||||
|
<Building2 class="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="truncate text-base">
|
||||||
|
{org.displayName}
|
||||||
|
</CardTitle>
|
||||||
|
<p class="truncate text-xs text-muted-foreground">
|
||||||
|
{org.slug}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<CardTitle class="truncate text-base">
|
|
||||||
{org.displayName}
|
|
||||||
</CardTitle>
|
|
||||||
<p class="truncate text-xs text-muted-foreground">
|
|
||||||
{org.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent class="pt-0">
|
||||||
<CardContent class="pt-0">
|
<p class="text-xs text-muted-foreground">
|
||||||
<p class="text-xs text-muted-foreground">
|
Created {formatDate(new Date(org.createdAt))}
|
||||||
Created {formatDate(new Date(org.createdAt))}
|
</p>
|
||||||
</p>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</a>
|
||||||
</a>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user