Merge branch 'master' into cli-improvements-1
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "api-server",
|
||||
"name": "@reviq/api-server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist",
|
||||
"build": "bun build src/index.ts --compile --outfile dist/api-server",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --cache",
|
||||
"clean": "rm -rf dist .eslintcache",
|
||||
|
||||
@@ -112,36 +112,43 @@ export const createAuthMiddleware = () => {
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
const sessionInfo: Session = session
|
||||
? {
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
// For API token auth, create a synthetic session object
|
||||
// We know apiToken exists because userId came from it
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
|
||||
// Build auth info based on authentication method
|
||||
const authInfo: AuthInfo = session
|
||||
? {
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
method: "api_token",
|
||||
tokenId: apiToken?.id,
|
||||
tokenName: apiToken?.name,
|
||||
expiresAt: apiToken?.expires_at,
|
||||
lastUsedAt: apiToken?.last_used_at,
|
||||
createdAt: apiToken?.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
|
||||
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Admin routes - consolidated exports for os.router()
|
||||
*/
|
||||
|
||||
import { adminAuthCompleteLogin } from "./auth/complete-login.js";
|
||||
import { adminOrgsCreate } from "./orgs/create.js";
|
||||
import { adminOrgsDelete } from "./orgs/delete.js";
|
||||
import { adminOrgsGet } from "./orgs/get.js";
|
||||
import { adminOrgsList } from "./orgs/list.js";
|
||||
import {
|
||||
adminOrgsAddSite,
|
||||
adminOrgsListSites,
|
||||
adminOrgsRemoveSite,
|
||||
} from "./orgs/sites.js";
|
||||
import { adminOrgsUpdate } from "./orgs/update.js";
|
||||
import { adminUsersConfirmEmail } from "./users/confirm-email.js";
|
||||
import { adminUsersCreate } from "./users/create.js";
|
||||
import { adminUsersGet } from "./users/get.js";
|
||||
import { adminUsersList } from "./users/list.js";
|
||||
import { adminUsersUpdate } from "./users/update.js";
|
||||
|
||||
export const adminRoutes = {
|
||||
orgs: {
|
||||
list: adminOrgsList,
|
||||
get: adminOrgsGet,
|
||||
create: adminOrgsCreate,
|
||||
update: adminOrgsUpdate,
|
||||
delete: adminOrgsDelete,
|
||||
listSites: adminOrgsListSites,
|
||||
addSite: adminOrgsAddSite,
|
||||
removeSite: adminOrgsRemoveSite,
|
||||
},
|
||||
users: {
|
||||
list: adminUsersList,
|
||||
get: adminUsersGet,
|
||||
create: adminUsersCreate,
|
||||
update: adminUsersUpdate,
|
||||
confirmEmail: adminUsersConfirmEmail,
|
||||
},
|
||||
auth: {
|
||||
completeLogin: adminAuthCompleteLogin,
|
||||
},
|
||||
};
|
||||
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* admin.auth.completeLogin - Complete pending login request (dev helper)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const loginRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.where("completed_at", "is", null)
|
||||
.where("expires_at", ">", new Date())
|
||||
.orderBy("created_at", "desc")
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!loginRequest) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "No pending login request found",
|
||||
});
|
||||
}
|
||||
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", loginRequest.id)
|
||||
.execute();
|
||||
});
|
||||
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Admin procedure helpers - shared transformation functions
|
||||
*/
|
||||
|
||||
import type { OrgSites, Orgs, Users } from "@reviq/db-schema";
|
||||
import type { Selectable } from "kysely";
|
||||
|
||||
/** Transform org record to API response format */
|
||||
export const toOrgResponse = (org: Selectable<Orgs>) => ({
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
displayName: org.display_name,
|
||||
logoUrl: org.logo_url,
|
||||
createdAt: org.created_at,
|
||||
});
|
||||
|
||||
/** Transform user record to API response format */
|
||||
export const toUserResponse = (user: Selectable<Users>) => ({
|
||||
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,
|
||||
});
|
||||
|
||||
/** Transform site record to API response format */
|
||||
export const toSiteResponse = (site: Selectable<OrgSites>) => ({
|
||||
id: site.id,
|
||||
domain: site.domain,
|
||||
createdAt: site.created_at,
|
||||
});
|
||||
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* admin.orgs.create - Create organization with owner
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName, ownerEmail } = input;
|
||||
|
||||
// Find owner user by email (outside transaction - read-only)
|
||||
const owner = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", ownerEmail.toLowerCase())
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!owner) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
|
||||
// Create org and owner membership in transaction (with race condition protection)
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Check for existing org inside transaction to prevent race condition
|
||||
const existingOrg = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingOrg) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "Organization with this slug already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newOrg = await trx
|
||||
.insertInto("orgs")
|
||||
.values({
|
||||
slug,
|
||||
display_name: displayName,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("org_members")
|
||||
.values({
|
||||
org_id: newOrg.id,
|
||||
user_id: owner.id,
|
||||
role: "owner",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
return { slug };
|
||||
});
|
||||
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* admin.orgs.delete - Delete organization and all related records
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Delete org and related records in transaction
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
const org = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
await trx
|
||||
.deleteFrom("org_invites")
|
||||
.where("org_id", "=", org.id)
|
||||
.execute();
|
||||
await trx.deleteFrom("org_sites").where("org_id", "=", org.id).execute();
|
||||
await trx
|
||||
.deleteFrom("org_members")
|
||||
.where("org_id", "=", org.id)
|
||||
.execute();
|
||||
await trx.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
});
|
||||
});
|
||||
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* admin.orgs.get - Get organization by slug
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsGet = os.admin.orgs.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", input.slug)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return toOrgResponse(org);
|
||||
});
|
||||
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* admin.orgs.list - List all organizations
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||
return orgs.map(toOrgResponse);
|
||||
});
|
||||
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* admin.orgs.listSites, admin.orgs.addSite, admin.orgs.removeSite
|
||||
* Site management for organizations
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toSiteResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const sites = await context.db
|
||||
.selectFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
return sites.map(toSiteResponse);
|
||||
});
|
||||
|
||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
// Use transaction to prevent race condition on site creation
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
const org = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
// Check if site already exists (inside transaction)
|
||||
const existingSite = await trx
|
||||
.selectFrom("org_sites")
|
||||
.where("domain", "=", domain)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingSite) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "Site with this domain already exists",
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("org_sites")
|
||||
.values({
|
||||
org_id: org.id,
|
||||
domain,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.deleteFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.where("domain", "=", domain)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||
}
|
||||
});
|
||||
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* admin.orgs.update - Update organization
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Check if there are actual updates to make
|
||||
if (displayName === undefined && logoUrl === undefined) {
|
||||
// Verify org exists even for no-op
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<{
|
||||
display_name: string;
|
||||
logo_url: string | null;
|
||||
updated_at: Date;
|
||||
}> = { updated_at: new Date() };
|
||||
|
||||
if (displayName !== undefined) {
|
||||
updates.display_name = displayName;
|
||||
}
|
||||
if (logoUrl !== undefined) {
|
||||
updates.logo_url = logoUrl || null;
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("orgs")
|
||||
.set(updates)
|
||||
.where("slug", "=", slug)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
});
|
||||
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* admin.users.confirmEmail - Confirm a user's email (used by CLI)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
email_verified_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
});
|
||||
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* admin.users.create - Create passwordless user, optionally add to org
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { email, name, orgSlug, orgRole } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// If orgSlug provided, verify org exists (outside transaction - read-only)
|
||||
let orgId: number | undefined;
|
||||
if (orgSlug) {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", orgSlug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
orgId = org.id;
|
||||
}
|
||||
|
||||
// Create user in transaction (with race condition protection)
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Check for existing user inside transaction to prevent race condition
|
||||
const existingUser = await trx
|
||||
.selectFrom("users")
|
||||
.where("email", "=", normalizedEmail)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingUser) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "User with this email already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newUser = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
display_name: name ?? null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (orgId !== undefined) {
|
||||
await trx
|
||||
.insertInto("org_members")
|
||||
.values({
|
||||
org_id: orgId,
|
||||
user_id: newUser.id,
|
||||
role: orgRole ?? "member",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
});
|
||||
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* admin.users.get - Get user by email
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
if (!user) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return toUserResponse(user);
|
||||
});
|
||||
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* admin.users.list - List all users
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||
return users.map(toUserResponse);
|
||||
});
|
||||
48
apps/api-server/src/procedures/admin/users/update.ts
Normal file
48
apps/api-server/src/procedures/admin/users/update.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* admin.users.update - Update user properties (e.g., isSuperuser)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { email, isSuperuser } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// Check if there are actual updates to make
|
||||
if (isSuperuser === undefined) {
|
||||
// Verify user exists even for no-op
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", normalizedEmail)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!user) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent superuser from demoting themselves
|
||||
if (!isSuperuser && normalizedEmail === context.user.email.toLowerCase()) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Cannot remove your own superuser status",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
is_superuser: isSuperuser,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", normalizedEmail)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
});
|
||||
@@ -110,35 +110,43 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
const sessionInfo: Session = session
|
||||
? {
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
|
||||
// Build auth info based on authentication method
|
||||
const authInfo: AuthInfo = session
|
||||
? {
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
method: "api_token",
|
||||
tokenId: apiToken?.id,
|
||||
tokenName: apiToken?.name,
|
||||
expiresAt: apiToken?.expires_at,
|
||||
lastUsedAt: apiToken?.last_used_at,
|
||||
createdAt: apiToken?.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { adminRoutes } from "./procedures/admin/_routes.js";
|
||||
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
||||
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
||||
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js";
|
||||
@@ -241,91 +242,6 @@ const setupProfile = os.me.setupProfile
|
||||
// - invitesList, invitesCreate, invitesCancel, invitesAccept
|
||||
// - sitesList
|
||||
|
||||
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
|
||||
const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
// Admin users procedures
|
||||
const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
// Admin auth procedures
|
||||
const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.handler(async () => {
|
||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||
});
|
||||
|
||||
// Build the router
|
||||
export const router = os.router({
|
||||
auth: {
|
||||
@@ -389,26 +305,5 @@ export const router = os.router({
|
||||
list: sitesList,
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
orgs: {
|
||||
list: adminOrgsList,
|
||||
get: adminOrgsGet,
|
||||
create: adminOrgsCreate,
|
||||
update: adminOrgsUpdate,
|
||||
delete: adminOrgsDelete,
|
||||
listSites: adminOrgsListSites,
|
||||
addSite: adminOrgsAddSite,
|
||||
removeSite: adminOrgsRemoveSite,
|
||||
},
|
||||
users: {
|
||||
list: adminUsersList,
|
||||
get: adminUsersGet,
|
||||
create: adminUsersCreate,
|
||||
update: adminUsersUpdate,
|
||||
confirmEmail: adminUsersConfirmEmail,
|
||||
},
|
||||
auth: {
|
||||
completeLogin: adminAuthCompleteLogin,
|
||||
},
|
||||
},
|
||||
admin: adminRoutes,
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"reviq": "./dist/index.js",
|
||||
"__reviq_bash_complete": "./dist/bash-complete.js"
|
||||
"reviq": "./dist/reviq",
|
||||
"__reviq_bash_complete": "./dist/bash-complete"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/bin/reviq.ts --outfile dist/index.js --target bun && bun build src/bin/bash-complete.ts --outfile dist/bash-complete.js --target bun",
|
||||
"build": "bun build src/bin/reviq.ts --compile --outfile dist/reviq && bun build src/bin/bash-complete.ts --compile --outfile dist/bash-complete",
|
||||
"cli": "bun run src/bin/reviq.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --cache",
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -12,7 +12,7 @@
|
||||
},
|
||||
},
|
||||
"apps/api-server": {
|
||||
"name": "api-server",
|
||||
"name": "@reviq/api-server",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
@@ -372,6 +372,8 @@
|
||||
|
||||
"@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"],
|
||||
|
||||
"@reviq/api-server": ["@reviq/api-server@workspace:apps/api-server"],
|
||||
|
||||
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
|
||||
|
||||
"@reviq/db": ["@reviq/db@workspace:packages/db"],
|
||||
@@ -538,8 +540,6 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"api-server": ["api-server@workspace:apps/api-server"],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
@@ -76,6 +76,15 @@ publisher-dashboard/
|
||||
│ │ ├── index.ts # Server entry point (Bun.serve)
|
||||
│ │ ├── router.ts
|
||||
│ │ ├── procedures/
|
||||
│ │ │ ├── base.ts # Middleware (auth, superuser, loginRequest)
|
||||
│ │ │ ├── auth/ # Auth procedures
|
||||
│ │ │ ├── me/ # User self-management procedures
|
||||
│ │ │ └── admin/ # Superuser-only procedures
|
||||
│ │ │ ├── _routes.ts # Consolidated admin route exports
|
||||
│ │ │ ├── helpers.ts # Shared transform functions
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ ├── orgs/
|
||||
│ │ │ └── users/
|
||||
│ │ └── middleware/
|
||||
│ ├── publisher-dashboard/ # SvelteKit frontend
|
||||
│ │ ├── package.json
|
||||
@@ -2306,12 +2315,18 @@ _Can run parallel to H after F1 is done_
|
||||
|
||||
_Depends on: D1 (auth middleware)_
|
||||
|
||||
- [ ] **J1**: Implement org middleware (slug lookup, membership check)
|
||||
- [ ] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
|
||||
- [ ] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
|
||||
- [ ] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
|
||||
- [ ] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
|
||||
- [ ] **J6**: Implement `orgs.sites.list`
|
||||
- [x] **J1**: Implement org middleware (slug lookup, membership check)
|
||||
- [x] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
|
||||
- [x] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
|
||||
- [x] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
|
||||
- [x] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
|
||||
- [x] **J6**: Implement `orgs.sites.list`
|
||||
|
||||
_Implementation notes:_
|
||||
- Files in `procedures/orgs/` with `index.ts` for consolidated exports
|
||||
- Helper functions in `helpers.ts`: `lookupOrgBySlug`, `getMembership`, `requireRole`, `countOwners`
|
||||
- Race conditions prevented via Kysely transactions for owner count checks
|
||||
- Privilege escalation prevented: only owners can invite new owners
|
||||
|
||||
#### Workstream K: Admin Procedures (Backend)
|
||||
|
||||
@@ -2324,6 +2339,12 @@ _Can run parallel to J2-J6_
|
||||
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
|
||||
- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper)
|
||||
|
||||
_Implementation notes:_
|
||||
- Files in `procedures/admin/` with `_routes.ts` for consolidated exports
|
||||
- Helper functions in `helpers.ts`: `toOrgResponse`, `toUserResponse`, `toSiteResponse`
|
||||
- Race conditions prevented via transaction-scoped existence checks
|
||||
- Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status
|
||||
|
||||
#### Workstream L: Org Pages (Frontend)
|
||||
|
||||
_Depends on: J1-J6, C3_
|
||||
|
||||
Reference in New Issue
Block a user