Merge origin/master into reviq-auth-login-command
Resolved conflicts: - apps/api-server/src/router.ts: Use meRoutes from master - packages/api-contract/src/contract.ts: Keep master's nested sessions/devices/invites structure, add apiTokens Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
63
apps/api-server/src/procedures/me/_routes.ts
Normal file
63
apps/api-server/src/procedures/me/_routes.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Me routes - consolidated exports for os.router()
|
||||
*/
|
||||
|
||||
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
|
||||
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,
|
||||
},
|
||||
sessions: {
|
||||
list: listSessions,
|
||||
revoke: revokeSession,
|
||||
revokeAll: revokeAllSessions,
|
||||
},
|
||||
devices: {
|
||||
getInfo: getDeviceInfo,
|
||||
trust: trustDevice,
|
||||
listTrusted: listTrustedDevices,
|
||||
untrust: untrustDevice,
|
||||
revokeAll: revokeAllTrustedDevices,
|
||||
},
|
||||
apiTokens: {
|
||||
list: listApiTokens,
|
||||
create: createApiToken,
|
||||
delete: deleteApiToken,
|
||||
},
|
||||
};
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const getDeviceInfo = os.me.getDeviceInfo
|
||||
export const getDeviceInfo = os.me.devices.getInfo
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
@@ -48,7 +48,7 @@ export const getDeviceInfo = os.me.getDeviceInfo
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const trustDevice = os.me.trustDevice
|
||||
export const trustDevice = os.me.devices.trust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { name } = input;
|
||||
@@ -73,7 +73,7 @@ export const trustDevice = os.me.trustDevice
|
||||
* - Requires authentication
|
||||
* - Returns all trusted devices for the current user
|
||||
*/
|
||||
export const listTrustedDevices = os.me.listTrustedDevices
|
||||
export const listTrustedDevices = os.me.devices.listTrusted
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const devices = await context.db
|
||||
@@ -102,7 +102,7 @@ export const listTrustedDevices = os.me.listTrustedDevices
|
||||
* - Marks device as untrusted by ID
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const untrustDevice = os.me.untrustDevice
|
||||
export const untrustDevice = os.me.devices.untrust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
@@ -124,7 +124,7 @@ export const untrustDevice = os.me.untrustDevice
|
||||
* - Requires authentication
|
||||
* - Marks all devices as untrusted
|
||||
*/
|
||||
export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
|
||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
await context.db
|
||||
|
||||
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 };
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Returns all sessions for the current user
|
||||
* - Includes isCurrent flag to identify active session
|
||||
*/
|
||||
export const listSessions = os.me.listSessions
|
||||
export const listSessions = os.me.sessions.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const sessions = await context.db
|
||||
@@ -42,7 +42,7 @@ export const listSessions = os.me.listSessions
|
||||
* @throws NOT_FOUND if session doesn't exist
|
||||
* @throws BAD_REQUEST if trying to revoke current session
|
||||
*/
|
||||
export const revokeSession = os.me.revokeSession
|
||||
export const revokeSession = os.me.sessions.revoke
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { sessionId } = input;
|
||||
@@ -74,7 +74,7 @@ export const revokeSession = os.me.revokeSession
|
||||
* - Requires authentication
|
||||
* - Revokes all sessions except current
|
||||
*/
|
||||
export const revokeAllSessions = os.me.revokeAllSessions
|
||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
// Revoke all sessions except current
|
||||
|
||||
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 };
|
||||
});
|
||||
Reference in New Issue
Block a user