Merge branch 'master' into testing-improvements
This commit is contained in:
@@ -9,24 +9,41 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
|||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.use(superuserMiddleware)
|
.use(superuserMiddleware)
|
||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const loginRequest = await context.db
|
const email = input.email.toLowerCase();
|
||||||
|
|
||||||
|
// First check if any login request exists for this email
|
||||||
|
const anyRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.where("email", "=", email)
|
||||||
.where("completed_at", "is", null)
|
|
||||||
.where("expires_at", ">", new Date())
|
|
||||||
.orderBy("created_at", "desc")
|
.orderBy("created_at", "desc")
|
||||||
.select(["id"])
|
.select(["id", "completed_at", "expires_at"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!loginRequest) {
|
if (!anyRequest) {
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
message: "No pending login request found",
|
message: `No login request found for ${email}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if already completed
|
||||||
|
if (anyRequest.completed_at) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Login request already completed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message:
|
||||||
|
"Login request expired (15 min limit). Start a new login flow.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the login request
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", loginRequest.id)
|
.where("id", "=", anyRequest.id)
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
setCookie,
|
setCookie,
|
||||||
} from "../../utils/cookies.js";
|
} from "../../utils/cookies.js";
|
||||||
import {
|
import {
|
||||||
generateBase58Token,
|
|
||||||
generateDeviceFingerprint,
|
generateDeviceFingerprint,
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||||
import { isDeviceTrusted } from "../../utils/session.js";
|
import { isDeviceTrusted } from "../../utils/session.js";
|
||||||
@@ -62,7 +62,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
// Generate placeholder token (base58) for anti-enumeration
|
// Generate placeholder token (base58) for anti-enumeration
|
||||||
// This prevents attackers from knowing if an email exists based on response
|
// This prevents attackers from knowing if an email exists based on response
|
||||||
const placeholderToken = generateBase58Token();
|
const placeholderToken = generateSecureBase58Token("login_");
|
||||||
|
|
||||||
// Set placeholder login request token cookie
|
// Set placeholder login request token cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
@@ -107,7 +107,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
|
|
||||||
// Create login request with secure token
|
// Create login request with secure token
|
||||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
||||||
const token = generateBase58Token();
|
const token = generateSecureBase58Token("login_");
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
.insertInto("login_requests")
|
.insertInto("login_requests")
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
import {
|
||||||
|
generateExpiry,
|
||||||
|
generateSecureBase58Token,
|
||||||
|
} from "../../utils/crypto.js";
|
||||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
import { sendPasswordResetEmail } from "../../utils/email.js";
|
||||||
import { os } from "../base.js";
|
import { os } from "../base.js";
|
||||||
|
|
||||||
@@ -33,8 +36,8 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
|||||||
.where("user_id", "=", user.id)
|
.where("user_id", "=", user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Generate secure token (64 hex chars)
|
// Generate secure base58 token
|
||||||
const token = generateSecureToken();
|
const token = generateSecureBase58Token();
|
||||||
|
|
||||||
// Create password reset record with 1 hour expiry
|
// Create password reset record with 1 hour expiry
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||||
import { generateSecureToken } from "../../utils/crypto.js";
|
|
||||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { isDeviceTrusted } from "../../utils/session.js";
|
import { isDeviceTrusted } from "../../utils/session.js";
|
||||||
@@ -47,6 +46,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
"login_requests.id",
|
"login_requests.id",
|
||||||
"login_requests.user_id",
|
"login_requests.user_id",
|
||||||
"login_requests.email",
|
"login_requests.email",
|
||||||
|
"login_requests.token",
|
||||||
"login_requests.device_fingerprint",
|
"login_requests.device_fingerprint",
|
||||||
"login_requests.expires_at",
|
"login_requests.expires_at",
|
||||||
"login_requests.completed_at",
|
"login_requests.completed_at",
|
||||||
@@ -106,19 +106,9 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
.where("id", "=", result.id)
|
.where("id", "=", result.id)
|
||||||
.execute();
|
.execute();
|
||||||
} else {
|
} else {
|
||||||
// Device is untrusted - generate confirmation token and send email
|
// Device is untrusted - send confirmation email with existing token
|
||||||
const confirmationToken = generateSecureToken();
|
// The same base58 token is used for both cookie lookup and email confirmation
|
||||||
|
await sendLoginConfirmationEmail(result.email, result.token);
|
||||||
await context.db
|
|
||||||
.updateTable("login_requests")
|
|
||||||
.set({
|
|
||||||
token: confirmationToken,
|
|
||||||
})
|
|
||||||
.where("id", "=", result.id)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send login confirmation email
|
|
||||||
await sendLoginConfirmationEmail(result.email, confirmationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return void (success)
|
// Return void (success)
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
* Flow:
|
* Flow:
|
||||||
* 1. Check if email is already verified (return early if so)
|
* 1. Check if email is already verified (return early if so)
|
||||||
* 2. Delete any existing verification tokens for this user
|
* 2. Delete any existing verification tokens for this user
|
||||||
* 3. Generate new secure token (64 hex chars)
|
* 3. Generate new secure base58 token
|
||||||
* 4. Create new email_verifications record with 24 hour expiry
|
* 4. Create new email_verifications record with 24 hour expiry
|
||||||
* 5. Send verification email (stubbed)
|
* 5. Send verification email (stubbed)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
import {
|
||||||
|
generateExpiry,
|
||||||
|
generateSecureBase58Token,
|
||||||
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
import { sendVerificationEmail } from "../../utils/email.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
@@ -30,8 +33,8 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
|||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Generate new secure token
|
// Generate new secure base58 token
|
||||||
const token = generateSecureToken();
|
const token = generateSecureBase58Token();
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||||
|
|
||||||
// Create new verification record
|
// Create new verification record
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
setCookie,
|
setCookie,
|
||||||
TOKEN_DURATIONS,
|
TOKEN_DURATIONS,
|
||||||
} from "../../utils/cookies.js";
|
} from "../../utils/cookies.js";
|
||||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
import {
|
||||||
|
generateExpiry,
|
||||||
|
generateSecureBase58Token,
|
||||||
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
import { sendVerificationEmail } from "../../utils/email.js";
|
||||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||||
@@ -262,7 +265,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Generate verification token
|
// Generate verification token
|
||||||
const verificationToken = generateSecureToken();
|
const verificationToken = generateSecureBase58Token();
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||||
|
|
||||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
import {
|
||||||
|
generateExpiry,
|
||||||
|
generateSecureBase58Token,
|
||||||
|
} from "../../utils/crypto.js";
|
||||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authMiddleware, os } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||||
@@ -88,7 +91,7 @@ export const invitesCreate = os.orgs.invites.create
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate invite token and expiry
|
// Generate invite token and expiry
|
||||||
const token = generateSecureToken();
|
const token = generateSecureBase58Token();
|
||||||
const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60);
|
const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Cookie configuration for authentication
|
* Cookie configuration for authentication
|
||||||
* All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax
|
* All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax
|
||||||
|
*
|
||||||
|
* Uses oRPC cookie helpers for proper cookie handling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
deleteCookie,
|
||||||
|
getCookie,
|
||||||
|
setCookie,
|
||||||
|
} from "@orpc/server/helpers";
|
||||||
|
|
||||||
export const COOKIE_NAMES = {
|
export const COOKIE_NAMES = {
|
||||||
SESSION_TOKEN: "rev.session_token",
|
SESSION_TOKEN: "rev.session_token",
|
||||||
DEVICE_FINGERPRINT: "rev.device_fingerprint",
|
DEVICE_FINGERPRINT: "rev.device_fingerprint",
|
||||||
@@ -39,71 +47,3 @@ export const COOKIE_OPTIONS = {
|
|||||||
maxAge: COOKIE_DURATIONS.LOGIN_REQUEST,
|
maxAge: COOKIE_DURATIONS.LOGIN_REQUEST,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Cookie options type for setCookie function
|
|
||||||
*/
|
|
||||||
export interface CookieOptions {
|
|
||||||
httpOnly?: boolean;
|
|
||||||
secure?: boolean;
|
|
||||||
sameSite?: "strict" | "lax" | "none";
|
|
||||||
path?: string;
|
|
||||||
maxAge?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse cookie string and get a specific cookie value
|
|
||||||
*/
|
|
||||||
export const getCookie = (
|
|
||||||
headers: Headers,
|
|
||||||
name: string,
|
|
||||||
): string | undefined => {
|
|
||||||
const cookieHeader = headers.get("Cookie");
|
|
||||||
if (!cookieHeader) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
const [cookieName, ...valueParts] = cookie.split("=");
|
|
||||||
if (cookieName === name) {
|
|
||||||
return valueParts.join("=");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a cookie in the response headers
|
|
||||||
*/
|
|
||||||
export const setCookie = (
|
|
||||||
headers: Headers,
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
options: CookieOptions,
|
|
||||||
): void => {
|
|
||||||
const parts = [`${name}=${value}`];
|
|
||||||
if (options.httpOnly) {
|
|
||||||
parts.push("HttpOnly");
|
|
||||||
}
|
|
||||||
if (options.secure) {
|
|
||||||
parts.push("Secure");
|
|
||||||
}
|
|
||||||
if (options.sameSite) {
|
|
||||||
parts.push(`SameSite=${options.sameSite}`);
|
|
||||||
}
|
|
||||||
if (options.path) {
|
|
||||||
parts.push(`Path=${options.path}`);
|
|
||||||
}
|
|
||||||
if (options.maxAge) {
|
|
||||||
parts.push(`Max-Age=${String(options.maxAge)}`);
|
|
||||||
}
|
|
||||||
headers.append("Set-Cookie", parts.join("; "));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a cookie by setting it to expire immediately
|
|
||||||
*/
|
|
||||||
export const deleteCookie = (headers: Headers, name: string): void => {
|
|
||||||
headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { base58 } from "@scure/base";
|
import { base58 } from "@scure/base";
|
||||||
|
|
||||||
|
// Re-export generateSecureBase58Token from shared utils
|
||||||
|
export { generateSecureBase58Token } from "@reviq/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token prefix for all RevIQ API tokens
|
* Token prefix for all RevIQ API tokens
|
||||||
*/
|
*/
|
||||||
@@ -62,58 +65,6 @@ export const generateDeviceFingerprint = (): string => {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure random token for email verification, password reset, etc.
|
|
||||||
* Uses 32 bytes (256 bits) of entropy
|
|
||||||
* Uses Web Crypto API for Cloudflare Workers compatibility
|
|
||||||
*/
|
|
||||||
export const generateSecureToken = (): string => {
|
|
||||||
const bytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
return Array.from(bytes)
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base58 alphabet (Bitcoin-style, no 0, O, I, l)
|
|
||||||
*/
|
|
||||||
const BASE58_ALPHABET =
|
|
||||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a cryptographically secure base58 token
|
|
||||||
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
|
||||||
*/
|
|
||||||
export const generateBase58Token = (byteLength = 24): string => {
|
|
||||||
const bytes = new Uint8Array(byteLength);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
|
|
||||||
// Convert bytes to base58
|
|
||||||
let result = "";
|
|
||||||
let num = BigInt(0);
|
|
||||||
for (const byte of bytes) {
|
|
||||||
num = num * 256n + BigInt(byte);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (num > 0n) {
|
|
||||||
const remainder = Number(num % 58n);
|
|
||||||
result = BASE58_ALPHABET.charAt(remainder) + result;
|
|
||||||
num /= 58n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle leading zeros
|
|
||||||
for (const byte of bytes) {
|
|
||||||
if (byte === 0) {
|
|
||||||
result = BASE58_ALPHABET.charAt(0) + result;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate expiration date
|
* Generate expiration date
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@orpc/client": "^1.13.2",
|
||||||
|
"@orpc/contract": "^1.13.2",
|
||||||
|
"@reviq/api-contract": "workspace:*",
|
||||||
"@reviq/db": "workspace:*",
|
"@reviq/db": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"@stricli/auto-complete": "^1.0.0",
|
"@stricli/auto-complete": "^1.0.0",
|
||||||
|
|||||||
@@ -6,4 +6,10 @@ export const app = buildApplication(rootRouteMap, {
|
|||||||
versionInfo: {
|
versionInfo: {
|
||||||
currentVersion: "0.0.0",
|
currentVersion: "0.0.0",
|
||||||
},
|
},
|
||||||
|
scanner: {
|
||||||
|
caseStyle: "allow-kebab-for-camel",
|
||||||
|
},
|
||||||
|
documentation: {
|
||||||
|
caseStyle: "convert-camel-to-kebab",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildRouteMap } from "@stricli/core";
|
import { buildRouteMap } from "@stricli/core";
|
||||||
|
import { adminRouteMap } from "./admin/_command.js";
|
||||||
import { authRouteMap } from "./auth/_command.js";
|
import { authRouteMap } from "./auth/_command.js";
|
||||||
import { bootstrapCommand } from "./bootstrap.js";
|
import { bootstrapCommand } from "./bootstrap.js";
|
||||||
import { completionsCommand } from "./completions.js";
|
import { completionsCommand } from "./completions.js";
|
||||||
@@ -8,6 +9,7 @@ import { userRouteMap } from "./user/_command.js";
|
|||||||
export const rootRouteMap = buildRouteMap({
|
export const rootRouteMap = buildRouteMap({
|
||||||
routes: {
|
routes: {
|
||||||
bootstrap: bootstrapCommand,
|
bootstrap: bootstrapCommand,
|
||||||
|
admin: adminRouteMap,
|
||||||
auth: authRouteMap,
|
auth: authRouteMap,
|
||||||
user: userRouteMap,
|
user: userRouteMap,
|
||||||
org: orgRouteMap,
|
org: orgRouteMap,
|
||||||
|
|||||||
11
apps/cli/src/routes/admin/_command.ts
Normal file
11
apps/cli/src/routes/admin/_command.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { buildRouteMap } from "@stricli/core";
|
||||||
|
import { completeLoginCommand } from "./complete-login.js";
|
||||||
|
|
||||||
|
export const adminRouteMap = buildRouteMap({
|
||||||
|
routes: {
|
||||||
|
"complete-login": completeLoginCommand,
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
brief: "Admin commands (requires superuser)",
|
||||||
|
},
|
||||||
|
});
|
||||||
51
apps/cli/src/routes/admin/complete-login.ts
Normal file
51
apps/cli/src/routes/admin/complete-login.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { LocalContext } from "../../context.js";
|
||||||
|
import { ORPCError } from "@orpc/client";
|
||||||
|
import { buildCommand } from "@stricli/core";
|
||||||
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
|
||||||
|
interface CompleteLoginFlags {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeLogin(
|
||||||
|
this: LocalContext,
|
||||||
|
flags: CompleteLoginFlags,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const api = await createApiClient();
|
||||||
|
|
||||||
|
await api.admin.auth.completeLogin({
|
||||||
|
email: flags.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Completed login request for: ${flags.email}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ORPCError) {
|
||||||
|
console.error(`Error [${String(error.code)}]:`, error.message);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Error:",
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completeLoginCommand = buildCommand({
|
||||||
|
func: completeLogin,
|
||||||
|
parameters: {
|
||||||
|
flags: {
|
||||||
|
email: {
|
||||||
|
kind: "parsed",
|
||||||
|
parse: String,
|
||||||
|
brief: "Email address of user with pending login request",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
brief: "Complete pending login request",
|
||||||
|
fullDescription:
|
||||||
|
"Completes the most recent pending login request for a user. This is useful for development when email sending is not configured or to bypass email confirmation.",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,39 +4,11 @@ import { createApiClient } from "../../utils/api-client.js";
|
|||||||
import { getConfigPath, readConfig } from "../../utils/config.js";
|
import { getConfigPath, readConfig } from "../../utils/config.js";
|
||||||
import { TOKEN_PREFIX } from "../../utils/token.js";
|
import { TOKEN_PREFIX } from "../../utils/token.js";
|
||||||
|
|
||||||
interface AuthStatusResponse {
|
function formatDate(date: Date): string {
|
||||||
user: {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
displayName: string | null;
|
|
||||||
fullName: string | null;
|
|
||||||
isSuperuser: boolean;
|
|
||||||
emailVerified: boolean;
|
|
||||||
};
|
|
||||||
auth:
|
|
||||||
| {
|
|
||||||
method: "api_token";
|
|
||||||
tokenId: string;
|
|
||||||
tokenName: string;
|
|
||||||
expiresAt: string;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
method: "session";
|
|
||||||
sessionId: string;
|
|
||||||
expiresAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(date: Date): string {
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = date.getTime() - now.getTime();
|
const diffMs = date.getTime() - now.getTime();
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
@@ -86,8 +58,8 @@ async function status(this: LocalContext): Promise<void> {
|
|||||||
// Try to fetch status from API
|
// Try to fetch status from API
|
||||||
console.log("\nAPI Status:");
|
console.log("\nAPI Status:");
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const api = await createApiClient();
|
||||||
const response = await client.call<AuthStatusResponse>("me.authStatus");
|
const response = await api.me.authStatus();
|
||||||
|
|
||||||
// User info
|
// User info
|
||||||
console.log("\n User:");
|
console.log("\n User:");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { writeConfig } from "../utils/config.js";
|
|||||||
interface BootstrapFlags {
|
interface BootstrapFlags {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
dangerouslyOverwriteExisting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(
|
async function bootstrap(
|
||||||
@@ -28,6 +29,7 @@ async function bootstrap(
|
|||||||
const result = await executeBootstrap(db, {
|
const result = await executeBootstrap(db, {
|
||||||
email: flags.email,
|
email: flags.email,
|
||||||
password: flags.password,
|
password: flags.password,
|
||||||
|
dangerouslyOverwriteExisting: flags.dangerouslyOverwriteExisting,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Created superuser: ${result.user.email}`);
|
console.log(`Created superuser: ${result.user.email}`);
|
||||||
@@ -68,6 +70,11 @@ export const bootstrapCommand = buildCommand({
|
|||||||
parse: String,
|
parse: String,
|
||||||
brief: "Password for the superuser",
|
brief: "Password for the superuser",
|
||||||
},
|
},
|
||||||
|
dangerouslyOverwriteExisting: {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "Delete existing user and reviq org if they exist",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ interface AddSiteFlags {
|
|||||||
|
|
||||||
async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
|
async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const api = await createApiClient();
|
||||||
|
|
||||||
await client.call("admin.orgs.addSite", {
|
await api.admin.orgs.addSite({
|
||||||
slug: flags.org,
|
slug: flags.org,
|
||||||
domain: flags.domain,
|
domain: flags.domain,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ async function create(
|
|||||||
flags: CreateOrgFlags,
|
flags: CreateOrgFlags,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const api = await createApiClient();
|
||||||
|
|
||||||
const result = await client.call<{ slug: string }>("admin.orgs.create", {
|
const result = await api.admin.orgs.create({
|
||||||
slug: flags.slug,
|
slug: flags.slug,
|
||||||
displayName: flags.name,
|
displayName: flags.name,
|
||||||
ownerEmail: flags.owner,
|
ownerEmail: flags.owner,
|
||||||
|
|||||||
@@ -2,19 +2,11 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
|
||||||
interface OrgOutput {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
displayName: string;
|
|
||||||
logoUrl: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function list(this: LocalContext): Promise<void> {
|
async function list(this: LocalContext): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const api = await createApiClient();
|
||||||
|
|
||||||
const orgs = await client.call<OrgOutput[]>("admin.orgs.list", {});
|
const orgs = await api.admin.orgs.list();
|
||||||
|
|
||||||
if (orgs.length === 0) {
|
if (orgs.length === 0) {
|
||||||
console.log("No organizations found");
|
console.log("No organizations found");
|
||||||
@@ -27,7 +19,7 @@ async function list(this: LocalContext): Promise<void> {
|
|||||||
for (const org of orgs) {
|
for (const org of orgs) {
|
||||||
console.log(org.slug);
|
console.log(org.slug);
|
||||||
console.log(` Name: ${org.displayName}`);
|
console.log(` Name: ${org.displayName}`);
|
||||||
console.log(` Created: ${new Date(org.createdAt).toLocaleDateString()}`);
|
console.log(` Created: ${org.createdAt.toLocaleDateString()}`);
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ async function confirmEmail(
|
|||||||
flags: ConfirmEmailFlags,
|
flags: ConfirmEmailFlags,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const api = await createApiClient();
|
||||||
|
|
||||||
await client.call("admin.users.confirmEmail", {
|
await api.admin.users.confirmEmail({
|
||||||
email: flags.email,
|
email: flags.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
|
||||||
|
type OrgRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
|
const validRoles: OrgRole[] = ["owner", "admin", "member"];
|
||||||
|
|
||||||
|
function parseRole(role: string | undefined): OrgRole | undefined {
|
||||||
|
if (!role) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (validRoles.includes(role as OrgRole)) {
|
||||||
|
return role as OrgRole;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateUserFlags {
|
interface CreateUserFlags {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -14,13 +30,14 @@ async function create(
|
|||||||
flags: CreateUserFlags,
|
flags: CreateUserFlags,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const client = await createApiClient();
|
const orgRole = parseRole(flags.role);
|
||||||
|
const api = await createApiClient();
|
||||||
|
|
||||||
await client.call("admin.users.create", {
|
await api.admin.users.create({
|
||||||
email: flags.email,
|
email: flags.email,
|
||||||
name: flags.name,
|
name: flags.name,
|
||||||
orgSlug: flags.org,
|
orgSlug: flags.org,
|
||||||
orgRole: flags.role,
|
orgRole,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Created user: ${flags.email}`);
|
console.log(`Created user: ${flags.email}`);
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
* API client utilities for CLI commands
|
* API client utilities for CLI commands
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ContractRouterClient } from "@orpc/contract";
|
||||||
|
import type { contract } from "@reviq/api-contract";
|
||||||
|
import { createORPCClient } from "@orpc/client";
|
||||||
|
import { RPCLink } from "@orpc/client/fetch";
|
||||||
import { readConfig } from "./config.js";
|
import { readConfig } from "./config.js";
|
||||||
|
|
||||||
export interface ApiClientError {
|
export type ApiClient = ContractRouterClient<typeof contract>;
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API client with the stored credentials
|
* Create an oRPC API client with the stored credentials
|
||||||
* Throws an error if not logged in
|
* Throws an error if not logged in
|
||||||
*/
|
*/
|
||||||
export const createApiClient = async () => {
|
export const createApiClient = async (): Promise<ApiClient> => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -21,41 +22,13 @@ export const createApiClient = async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const link = new RPCLink({
|
||||||
/**
|
url: `${config.apiUrl}/api/v1/rpc`,
|
||||||
* Call an oRPC procedure
|
|
||||||
*/
|
|
||||||
call: async <T>(path: string, input?: unknown): Promise<T> => {
|
|
||||||
const url = `${config.apiUrl}/api/v1/rpc/${path}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-API-Key": config.token,
|
"X-API-Key": config.token,
|
||||||
},
|
},
|
||||||
body: input !== undefined ? JSON.stringify(input) : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// Cast to ApiClient for type-safe API calls
|
||||||
const text = await response.text();
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
let errorMessage = `API error: ${String(response.status)} ${response.statusText}`;
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(text) as ApiClientError;
|
|
||||||
if (error.message) {
|
|
||||||
errorMessage = error.message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (text) {
|
|
||||||
errorMessage = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
},
|
|
||||||
config,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiClient = Awaited<ReturnType<typeof createApiClient>>;
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as SuperuserBadge } from "./superuser-badge.svelte";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Shield } from "@lucide/svelte";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Badge variant="destructive" class="gap-1">
|
||||||
|
<Shield class="h-3 w-3" />
|
||||||
|
Superuser
|
||||||
|
</Badge>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// Check if current path is an auth page (doesn't require login)
|
||||||
|
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
|
||||||
|
|
||||||
|
// Fetch user to check if logged in (only for non-auth pages)
|
||||||
|
const userQuery = createQuery(() => ({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
enabled: !isAuthPage,
|
||||||
|
retry: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated on non-auth pages
|
||||||
|
$effect(() => {
|
||||||
|
if (!isAuthPage && userQuery.error) {
|
||||||
|
goto(`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isAuthPage || userQuery.data || userQuery.isPending}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { default as AuthGuard } from "./auth-guard.svelte";
|
||||||
export { default as ErrorAlert } from "./error-alert.svelte";
|
export { default as ErrorAlert } from "./error-alert.svelte";
|
||||||
export { default as PasswordFormField } from "./password-form-field.svelte";
|
export { default as PasswordFormField } from "./password-form-field.svelte";
|
||||||
export { default as PasswordInput } from "./password-input.svelte";
|
export { default as PasswordInput } from "./password-input.svelte";
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,6 +10,14 @@ interface Props {
|
|||||||
|
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
|
// Fetch current user to check superuser status
|
||||||
|
const userQuery = createQuery(() => ({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isSuperuser = $derived(userQuery.data?.isSuperuser ?? false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
icon: "home",
|
icon: "home",
|
||||||
@@ -38,6 +48,13 @@ const bottomItems = [
|
|||||||
label: "Settings",
|
label: "Settings",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin nav item (only shown for superusers)
|
||||||
|
const adminItem = {
|
||||||
|
icon: "shield",
|
||||||
|
href: "/admin",
|
||||||
|
label: "Admin",
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
@@ -152,6 +169,43 @@ const bottomItems = [
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Admin link (superusers only) -->
|
||||||
|
{#if isSuperuser}
|
||||||
|
{@const isActive = $page.url.pathname.startsWith(adminItem.href)}
|
||||||
|
<a
|
||||||
|
href={adminItem.href}
|
||||||
|
class={cn(
|
||||||
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
|
isActive
|
||||||
|
? "bg-destructive/20 text-destructive"
|
||||||
|
: "text-sidebar-muted hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
)}
|
||||||
|
aria-label={adminItem.label}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{#if isActive}
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{adminItem.label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom items -->
|
<!-- Bottom items -->
|
||||||
<div class="mt-auto flex flex-col items-center gap-3">
|
<div class="mt-auto flex flex-col items-center gap-3">
|
||||||
{#each bottomItems as item}
|
{#each bottomItems as item}
|
||||||
|
|||||||
31
apps/publisher-dashboard/src/lib/utils/format-date.ts
Normal file
31
apps/publisher-dashboard/src/lib/utils/format-date.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Date formatting utilities for consistent display across the app
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display in tables and lists
|
||||||
|
* Example: "Jan 15, 2024"
|
||||||
|
*/
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date with time for detailed views
|
||||||
|
* Example: "Jan 15, 2024, 3:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: string | Date): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Snippet } from "svelte";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
||||||
import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools";
|
import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools";
|
||||||
import { Toaster } from "svelte-sonner";
|
import { Toaster } from "svelte-sonner";
|
||||||
|
import { AuthGuard } from "$lib/components/auth";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -22,7 +23,11 @@ const queryClient = new QueryClient({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthGuard>
|
||||||
|
{#snippet children()}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{/snippet}
|
||||||
|
</AuthGuard>
|
||||||
<SvelteQueryDevtools />
|
<SvelteQueryDevtools />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
<Toaster richColors position="top-center" />
|
<Toaster richColors position="top-center" />
|
||||||
|
|||||||
51
apps/publisher-dashboard/src/routes/admin/+layout.svelte
Normal file
51
apps/publisher-dashboard/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// Fetch current user to check superuser status
|
||||||
|
const userQuery = createQuery(() => ({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Redirect non-superusers
|
||||||
|
$effect(() => {
|
||||||
|
if (userQuery.data && !userQuery.data.isSuperuser) {
|
||||||
|
toast.error("Access denied. Superuser privileges required.");
|
||||||
|
goto("/dashboard");
|
||||||
|
}
|
||||||
|
if (userQuery.error) {
|
||||||
|
goto(
|
||||||
|
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide admin context to child pages
|
||||||
|
setContext("adminContext", {
|
||||||
|
get userQuery() {
|
||||||
|
return userQuery;
|
||||||
|
},
|
||||||
|
get isSuperuser() {
|
||||||
|
return userQuery.data?.isSuperuser ?? false;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return userQuery.isPending;
|
||||||
|
},
|
||||||
|
get user() {
|
||||||
|
return userQuery.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
112
apps/publisher-dashboard/src/routes/admin/+page.svelte
Normal file
112
apps/publisher-dashboard/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Dashboard page - overview of admin resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Fetch all orgs
|
||||||
|
const orgsQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "orgs"],
|
||||||
|
queryFn: () => api.admin.orgs.list(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fetch all users
|
||||||
|
const usersQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "users"],
|
||||||
|
queryFn: () => api.admin.users.list(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isLoading = $derived(orgsQuery.isPending || usersQuery.isPending);
|
||||||
|
const hasError = $derived(orgsQuery.error || usersQuery.error);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Dashboard | Publisher Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="Admin Dashboard">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Admin badge -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">Admin</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<!-- Loading state -->
|
||||||
|
<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 admin data...</p>
|
||||||
|
</div>
|
||||||
|
{:else if hasError}
|
||||||
|
<!-- Error state -->
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||||
|
<p class="mt-4 text-sm text-destructive">
|
||||||
|
{hasError instanceof Error ? hasError.message : "Failed to load admin data"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<!-- Organizations card -->
|
||||||
|
<a href="/admin/orgs" class="group block transition-transform hover:scale-[1.02]">
|
||||||
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Building class="h-4 w-4" />
|
||||||
|
Organizations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-3xl font-bold">{orgsQuery.data?.length ?? 0}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Total organizations</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Users card -->
|
||||||
|
<a href="/admin/users" class="group block transition-transform hover:scale-[1.02]">
|
||||||
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-3xl font-bold">{usersQuery.data?.length ?? 0}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Total users</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button href="/admin/orgs/new">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
New Organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
215
apps/publisher-dashboard/src/routes/admin/orgs/+page.svelte
Normal file
215
apps/publisher-dashboard/src/routes/admin/orgs/+page.svelte
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Building,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "$lib/components/ui/table/index.js";
|
||||||
|
import { formatDate } from "$lib/utils/format-date.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Organizations list page
|
||||||
|
*/
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch all orgs
|
||||||
|
const orgsQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "orgs"],
|
||||||
|
queryFn: () => api.admin.orgs.list(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Confirmation dialog state
|
||||||
|
let confirmDialogOpen = $state(false);
|
||||||
|
let confirmDialogTitle = $state("");
|
||||||
|
let confirmDialogDescription = $state("");
|
||||||
|
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||||
|
let isConfirmLoading = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle delete org action
|
||||||
|
*/
|
||||||
|
function handleDelete(slug: string, displayName: string) {
|
||||||
|
confirmDialogTitle = "Delete Organization";
|
||||||
|
confirmDialogDescription = `Are you sure you want to delete "${displayName}" (${slug})? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
||||||
|
confirmAction = async () => {
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.delete({ slug });
|
||||||
|
toast.success("Organization deleted");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Failed to delete organization",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
confirmDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute confirm action
|
||||||
|
*/
|
||||||
|
async function executeConfirmAction() {
|
||||||
|
isConfirmLoading = true;
|
||||||
|
try {
|
||||||
|
await confirmAction();
|
||||||
|
confirmDialogOpen = false;
|
||||||
|
} finally {
|
||||||
|
isConfirmLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Organizations | Admin | Publisher Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="Organizations">
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if orgsQuery.isPending}
|
||||||
|
<!-- Loading state -->
|
||||||
|
<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 organizations...</p>
|
||||||
|
</div>
|
||||||
|
{:else if orgsQuery.error}
|
||||||
|
<!-- Error state -->
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||||
|
<p class="mt-4 text-sm text-destructive">
|
||||||
|
{orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if orgsQuery.data}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
Organizations ({orgsQuery.data.length})
|
||||||
|
</h2>
|
||||||
|
<Button href="/admin/orgs/new">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
New Organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if orgsQuery.data.length === 0}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Card class="border-dashed">
|
||||||
|
<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">
|
||||||
|
<Building class="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold">No organizations yet</h3>
|
||||||
|
<p class="mt-2 text-center text-sm text-muted-foreground">
|
||||||
|
Create your first organization to get started.
|
||||||
|
</p>
|
||||||
|
<Button href="/admin/orgs/new" class="mt-4">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
New Organization
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<!-- Organizations table -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Building class="h-4 w-4" />
|
||||||
|
All Organizations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Display Name</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead class="w-[120px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each orgsQuery.data as org (org.id)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="font-mono text-sm">{org.slug}</TableCell>
|
||||||
|
<TableCell class="font-medium">{org.displayName}</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">
|
||||||
|
{formatDate(org.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
href="/dashboard/{org.slug}"
|
||||||
|
title="View organization"
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
<span class="sr-only">View</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
onclick={() => handleDelete(org.slug, org.displayName)}
|
||||||
|
title="Delete organization"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← Back to admin dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
|
||||||
|
<!-- Confirmation dialog -->
|
||||||
|
<ConfirmDialog
|
||||||
|
bind:open={confirmDialogOpen}
|
||||||
|
title={confirmDialogTitle}
|
||||||
|
description={confirmDialogDescription}
|
||||||
|
variant="destructive"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
loading={isConfirmLoading}
|
||||||
|
onconfirm={executeConfirmAction}
|
||||||
|
oncancel={() => confirmDialogOpen = false}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
Building,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { 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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import { ConfirmDialog } from "$lib/components/org";
|
||||||
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "$lib/components/ui/table";
|
||||||
|
import { formatDate } from "$lib/utils/format-date.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin organization details page
|
||||||
|
* Allows superusers to view and manage individual organizations
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types from API contract
|
||||||
|
type OrgOutput = Awaited<ReturnType<typeof api.admin.orgs.get>>;
|
||||||
|
type OrgSiteOutput = Awaited<
|
||||||
|
ReturnType<typeof api.admin.orgs.listSites>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
// Get slug from URL params
|
||||||
|
const slug = $derived(page.params.slug);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch org details
|
||||||
|
const orgQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "orgs", slug],
|
||||||
|
queryFn: () => api.admin.orgs.get({ slug: slug ?? "" }),
|
||||||
|
enabled: !!slug,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fetch sites
|
||||||
|
const sitesQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "orgs", slug, "sites"],
|
||||||
|
queryFn: () => api.admin.orgs.listSites({ slug: slug ?? "" }),
|
||||||
|
enabled: !!slug,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let displayName = $state("");
|
||||||
|
let logoUrl = $state("");
|
||||||
|
let newDomain = $state("");
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let isAddingSite = $state(false);
|
||||||
|
|
||||||
|
// Confirm dialog state
|
||||||
|
let confirmDialogOpen = $state(false);
|
||||||
|
let confirmDialogTitle = $state("");
|
||||||
|
let confirmDialogDescription = $state("");
|
||||||
|
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||||
|
let confirmDialogConfirmLabel = $state("Confirm");
|
||||||
|
let isConfirmLoading = $state(false);
|
||||||
|
let pendingAction: (() => Promise<void>) | null = $state(null);
|
||||||
|
|
||||||
|
// Initialize form from query data
|
||||||
|
$effect(() => {
|
||||||
|
if (orgQuery.data) {
|
||||||
|
displayName = orgQuery.data.displayName;
|
||||||
|
logoUrl = orgQuery.data.logoUrl ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dirty check
|
||||||
|
const isDirty = $derived(
|
||||||
|
displayName !== (orgQuery.data?.displayName ?? "") ||
|
||||||
|
logoUrl !== (orgQuery.data?.logoUrl ?? ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save org settings
|
||||||
|
*/
|
||||||
|
async function handleSave() {
|
||||||
|
if (!(isDirty && slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.update({
|
||||||
|
slug,
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
logoUrl: logoUrl.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success("Organization updated");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs", slug] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Failed to update organization",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a site to the organization
|
||||||
|
*/
|
||||||
|
async function handleAddSite() {
|
||||||
|
const domain = newDomain.trim();
|
||||||
|
if (!(domain && slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAddingSite = true;
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.addSite({ slug, domain });
|
||||||
|
toast.success(`Site "${domain}" added`);
|
||||||
|
newDomain = "";
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "orgs", slug, "sites"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to add site");
|
||||||
|
} finally {
|
||||||
|
isAddingSite = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a site from the organization
|
||||||
|
*/
|
||||||
|
function handleRemoveSite(domain: string) {
|
||||||
|
confirmDialogTitle = "Remove Site";
|
||||||
|
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
|
||||||
|
confirmDialogVariant = "destructive";
|
||||||
|
confirmDialogConfirmLabel = "Remove Site";
|
||||||
|
pendingAction = async () => {
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
|
||||||
|
toast.success(`Site "${domain}" removed`);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "orgs", slug, "sites"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to remove site");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
confirmDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the organization
|
||||||
|
*/
|
||||||
|
function handleDelete() {
|
||||||
|
confirmDialogTitle = "Delete Organization";
|
||||||
|
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
||||||
|
confirmDialogVariant = "destructive";
|
||||||
|
confirmDialogConfirmLabel = "Delete Organization";
|
||||||
|
pendingAction = async () => {
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.delete({ slug: slug ?? "" });
|
||||||
|
toast.success("Organization deleted");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
||||||
|
goto("/admin/orgs");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Failed to delete organization",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
confirmDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute pending confirm action
|
||||||
|
*/
|
||||||
|
async function executeConfirmAction() {
|
||||||
|
if (!pendingAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isConfirmLoading = true;
|
||||||
|
try {
|
||||||
|
await pendingAction();
|
||||||
|
confirmDialogOpen = false;
|
||||||
|
} finally {
|
||||||
|
isConfirmLoading = false;
|
||||||
|
pendingAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{orgQuery.data?.displayName ?? "Organization"} | Admin | Publisher Dashboard
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="Organization Details">
|
||||||
|
{#if orgQuery.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 organization...</p>
|
||||||
|
</div>
|
||||||
|
{:else if orgQuery.error}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||||
|
<p class="mt-4 text-sm text-destructive">
|
||||||
|
{orgQuery.error instanceof Error
|
||||||
|
? orgQuery.error.message
|
||||||
|
: "Failed to load organization"}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/admin/orgs"
|
||||||
|
class="mt-4 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="mr-1 inline h-4 w-4" />
|
||||||
|
Back to organizations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else if orgQuery.data}
|
||||||
|
{@const org = orgQuery.data}
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a
|
||||||
|
href="/admin/orgs"
|
||||||
|
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="mr-1 h-4 w-4" />
|
||||||
|
Back to organizations
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{#if org.logoUrl}
|
||||||
|
<img
|
||||||
|
src={org.logoUrl}
|
||||||
|
alt="{org.displayName} logo"
|
||||||
|
class="h-16 w-16 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-lg bg-muted"
|
||||||
|
>
|
||||||
|
<Building class="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Slug: <code class="rounded bg-muted px-1.5 py-0.5">{org.slug}</code>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Created {formatDate(org.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Settings Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the organization's display name and logo.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="display-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Organization Name"
|
||||||
|
bind:value={displayName}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="logo-url">Logo URL</Label>
|
||||||
|
<Input
|
||||||
|
id="logo-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
bind:value={logoUrl}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Optional. Enter a URL to the organization's logo image.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={isSaving || !isDirty || !displayName.trim()}
|
||||||
|
>
|
||||||
|
{#if isSaving}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Sites Management Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Globe class="h-4 w-4" />
|
||||||
|
Sites ({sitesQuery.data?.length ?? 0})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage the sites associated with this organization.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
{#if sitesQuery.isPending}
|
||||||
|
<div class="flex items-center justify-center py-4">
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{:else if sitesQuery.error}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{sitesQuery.error instanceof Error
|
||||||
|
? sitesQuery.error.message
|
||||||
|
: "Failed to load sites"}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if sitesQuery.data && sitesQuery.data.length > 0}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Domain</TableHead>
|
||||||
|
<TableHead class="w-[100px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each sitesQuery.data as site (site.id)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="font-medium">{site.domain}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
onclick={() => handleRemoveSite(site.domain)}
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-1 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No sites configured yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add site form -->
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddSite();
|
||||||
|
}}
|
||||||
|
class="flex items-end gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<Label for="new-domain">Add Site</Label>
|
||||||
|
<Input
|
||||||
|
id="new-domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
bind:value={newDomain}
|
||||||
|
disabled={isAddingSite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAddingSite || !newDomain.trim()}
|
||||||
|
>
|
||||||
|
{#if isAddingSite}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Danger Zone Card -->
|
||||||
|
<Card class="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base text-destructive">
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Irreversible actions that permanently affect this organization.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert variant="destructive" class="mb-4">
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Deleting this organization will permanently remove all members,
|
||||||
|
invitations, and sites. This action cannot be undone.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button variant="destructive" onclick={handleDelete}>
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Delete Organization
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</DashboardLayout>
|
||||||
|
|
||||||
|
<!-- Confirmation dialog -->
|
||||||
|
<ConfirmDialog
|
||||||
|
bind:open={confirmDialogOpen}
|
||||||
|
title={confirmDialogTitle}
|
||||||
|
description={confirmDialogDescription}
|
||||||
|
variant={confirmDialogVariant}
|
||||||
|
confirmLabel={confirmDialogConfirmLabel}
|
||||||
|
loading={isConfirmLoading}
|
||||||
|
onconfirm={executeConfirmAction}
|
||||||
|
oncancel={() => {
|
||||||
|
confirmDialogOpen = false;
|
||||||
|
pendingAction = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
160
apps/publisher-dashboard/src/routes/admin/orgs/new/+page.svelte
Normal file
160
apps/publisher-dashboard/src/routes/admin/orgs/new/+page.svelte
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Loader2 } from "@lucide/svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin New Organization form page
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let slug = $state("");
|
||||||
|
let displayName = $state("");
|
||||||
|
let ownerEmail = $state("");
|
||||||
|
let isSaving = $state(false);
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
const isValid = $derived(
|
||||||
|
slug.trim().length > 0 &&
|
||||||
|
displayName.trim().length > 0 &&
|
||||||
|
ownerEmail.trim().length > 0 &&
|
||||||
|
/^[a-z0-9-]+$/.test(slug.trim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission
|
||||||
|
*/
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid || isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await api.admin.orgs.create({
|
||||||
|
slug: slug.trim(),
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
ownerEmail: ownerEmail.trim(),
|
||||||
|
});
|
||||||
|
toast.success("Organization created successfully");
|
||||||
|
goto("/admin/orgs");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Failed to create organization",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate slug format on input
|
||||||
|
*/
|
||||||
|
function handleSlugInput(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
// Convert to lowercase and replace invalid characters
|
||||||
|
input.value = input.value.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
||||||
|
slug = input.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Organization | Admin | Publisher Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="New Organization">
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a
|
||||||
|
href="/admin/orgs"
|
||||||
|
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="mr-1 h-4 w-4" />
|
||||||
|
Back to organizations
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Form card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>New Organization</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new organization. The owner will receive access automatically.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||||
|
<!-- Slug field -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="my-organization"
|
||||||
|
value={slug}
|
||||||
|
oninput={handleSlugInput}
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Lowercase letters, numbers, and hyphens only. Used in URLs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Name field -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="display-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="My Organization"
|
||||||
|
bind:value={displayName}
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
The name shown in the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Owner Email field -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="owner-email">Owner Email</Label>
|
||||||
|
<Input
|
||||||
|
id="owner-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="owner@example.com"
|
||||||
|
bind:value={ownerEmail}
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
The email of the user who will own this organization. If the user does not exist, they will be created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit button -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<Button type="submit" disabled={!isValid || isSaving}>
|
||||||
|
{#if isSaving}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Create Organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
113
apps/publisher-dashboard/src/routes/admin/users/+page.svelte
Normal file
113
apps/publisher-dashboard/src/routes/admin/users/+page.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertCircle, Check, Eye, Loader2, Users, X } from "@lucide/svelte";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
||||||
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "$lib/components/ui/table/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user list page
|
||||||
|
* Displays all users in the system with their status
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Fetch all users
|
||||||
|
const usersQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "users"],
|
||||||
|
queryFn: () => api.admin.users.list(),
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Users | Admin | Publisher Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="Users">
|
||||||
|
{#if usersQuery.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 users...</p>
|
||||||
|
</div>
|
||||||
|
{:else if usersQuery.error}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||||
|
<p class="mt-4 text-sm text-destructive">
|
||||||
|
{usersQuery.error instanceof Error ? usersQuery.error.message : "Failed to load users"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if usersQuery.data}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
Users ({usersQuery.data.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if usersQuery.data.length > 0}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Display Name</TableHead>
|
||||||
|
<TableHead>Email Verified</TableHead>
|
||||||
|
<TableHead>Superuser</TableHead>
|
||||||
|
<TableHead class="w-[100px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each usersQuery.data as user (user.id)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="font-medium">{user.email}</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">
|
||||||
|
{user.displayName ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{#if user.emailVerified}
|
||||||
|
<Check class="h-4 w-4 text-green-600" />
|
||||||
|
{:else}
|
||||||
|
<X class="h-4 w-4 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{#if user.isSuperuser}
|
||||||
|
<SuperuserBadge />
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
href="/admin/users/{encodeURIComponent(user.email)}"
|
||||||
|
>
|
||||||
|
<Eye class="mr-1 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No users found</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</DashboardLayout>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { api } from "$lib/api/client.js";
|
||||||
|
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
||||||
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||||
|
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user details page
|
||||||
|
* Displays user profile and allows permission management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Email comes URL-encoded, SvelteKit auto-decodes it
|
||||||
|
const email = $derived(page.params.email);
|
||||||
|
|
||||||
|
// Fetch user details
|
||||||
|
const userDetailsQuery = createQuery(() => ({
|
||||||
|
queryKey: ["admin", "users", email],
|
||||||
|
queryFn: () => api.admin.users.get({ email: email ?? "" }),
|
||||||
|
enabled: !!email,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get current logged-in user to check if viewing self
|
||||||
|
const meQuery = createQuery(() => ({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if viewing self
|
||||||
|
const isViewingSelf = $derived(
|
||||||
|
meQuery.data?.email?.toLowerCase() === email?.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track superuser toggle state and loading states
|
||||||
|
let isSuperuser = $state(false);
|
||||||
|
let hasChanges = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let isConfirmingEmail = $state(false);
|
||||||
|
|
||||||
|
// Sync state when user data loads
|
||||||
|
$effect(() => {
|
||||||
|
if (userDetailsQuery.data) {
|
||||||
|
isSuperuser = userDetailsQuery.data.isSuperuser;
|
||||||
|
hasChanges = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
$effect(() => {
|
||||||
|
if (userDetailsQuery.data) {
|
||||||
|
hasChanges = isSuperuser !== userDetailsQuery.data.isSuperuser;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initials from display name or email
|
||||||
|
*/
|
||||||
|
function getInitials(
|
||||||
|
name: string | null | undefined,
|
||||||
|
emailAddr: string,
|
||||||
|
): string {
|
||||||
|
if (name) {
|
||||||
|
const parts = name.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return emailAddr.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle save permissions
|
||||||
|
*/
|
||||||
|
async function handleSavePermissions() {
|
||||||
|
if (!email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await api.admin.users.update({ email, isSuperuser });
|
||||||
|
toast.success("User permissions updated");
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "users", email],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||||
|
hasChanges = false;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to update user");
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle confirm email
|
||||||
|
*/
|
||||||
|
async function handleConfirmEmail() {
|
||||||
|
if (!email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfirmingEmail = true;
|
||||||
|
try {
|
||||||
|
await api.admin.users.confirmEmail({ email });
|
||||||
|
toast.success("Email confirmed successfully");
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "users", email],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to confirm email");
|
||||||
|
} finally {
|
||||||
|
isConfirmingEmail = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<DashboardLayout title="User Details">
|
||||||
|
<!-- Back navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/admin/users" class="gap-1">
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Back to users
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if userDetailsQuery.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 user...</p>
|
||||||
|
</div>
|
||||||
|
{:else if userDetailsQuery.error}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||||
|
<p class="mt-4 text-sm text-destructive">
|
||||||
|
{userDetailsQuery.error instanceof Error
|
||||||
|
? userDetailsQuery.error.message
|
||||||
|
: "Failed to load user"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if userDetailsQuery.data}
|
||||||
|
{@const user = userDetailsQuery.data}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xl font-semibold"
|
||||||
|
>
|
||||||
|
{getInitials(user.displayName, user.email)}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-2xl font-semibold">
|
||||||
|
{user.displayName ?? user.email}
|
||||||
|
</h2>
|
||||||
|
{#if user.isSuperuser}
|
||||||
|
<SuperuserBadge />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Mail class="h-3 w-3" />
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Profile Info Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
Profile Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Read-only user profile details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Email</dt>
|
||||||
|
<dd class="mt-1">{user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Display Name</dt>
|
||||||
|
<dd class="mt-1">{user.displayName ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Full Name</dt>
|
||||||
|
<dd class="mt-1">{user.fullName ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Phone Number</dt>
|
||||||
|
<dd class="mt-1">{user.phoneNumber ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Email Verified</dt>
|
||||||
|
<dd class="mt-1 flex items-center gap-2">
|
||||||
|
{#if user.emailVerified}
|
||||||
|
<Check class="h-4 w-4 text-green-600" />
|
||||||
|
<span>Yes</span>
|
||||||
|
{:else}
|
||||||
|
<X class="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>No</span>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Permissions Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Permissions</CardTitle>
|
||||||
|
<CardDescription>Manage user access levels</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if isViewingSelf}
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
You cannot modify your own superuser status. Another superuser must make this
|
||||||
|
change.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<label class="flex cursor-pointer items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSuperuser}
|
||||||
|
onchange={(e) => (isSuperuser = e.currentTarget.checked)}
|
||||||
|
disabled={isViewingSelf || isSaving}
|
||||||
|
class="h-4 w-4 rounded border-input bg-background text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium leading-none">Grant superuser privileges</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
{#if !isViewingSelf}
|
||||||
|
<CardFooter>
|
||||||
|
<Button onclick={handleSavePermissions} disabled={!hasChanges || isSaving}>
|
||||||
|
{#if isSaving}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Actions Card -->
|
||||||
|
{#if !user.emailVerified}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Actions</CardTitle>
|
||||||
|
<CardDescription>Administrative actions for this user</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="secondary" onclick={handleConfirmEmail} disabled={isConfirmingEmail}>
|
||||||
|
{#if isConfirmingEmail}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Confirm Email
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</DashboardLayout>
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertCircle, Loader2, Mail, RefreshCw } from "@lucide/svelte";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
RefreshCw,
|
||||||
|
} 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";
|
||||||
@@ -16,6 +23,19 @@ import {
|
|||||||
let resendCooldown = $state(0);
|
let resendCooldown = $state(0);
|
||||||
let isResending = $state(false);
|
let isResending = $state(false);
|
||||||
let resendError = $state<string | null>(null);
|
let resendError = $state<string | null>(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
const devCommand = $derived(
|
||||||
|
`reviq admin complete-login --email ${loginFlowState.email}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
await navigator.clipboard.writeText(devCommand);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -154,9 +174,23 @@ function handleDifferentEmail() {
|
|||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
To complete login without email, use the CLI command:
|
To complete login without email, use the CLI command:
|
||||||
</p>
|
</p>
|
||||||
<code class="mt-2 block rounded bg-muted px-2 py-1 text-xs">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
bun run cli auth complete-login --email {loginFlowState.email}
|
<code class="flex-1 rounded bg-muted px-2 py-1 text-xs">
|
||||||
|
{devCommand}
|
||||||
</code>
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyToClipboard}
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{#if copied}
|
||||||
|
<Check class="h-4 w-4 text-green-500" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { setContext } from "svelte";
|
import { setContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
|
||||||
@@ -24,14 +23,24 @@ const userQuery = createQuery(() => ({
|
|||||||
// Fetch org members
|
// Fetch org members
|
||||||
const membersQuery = createQuery(() => ({
|
const membersQuery = createQuery(() => ({
|
||||||
queryKey: ["org", slug, "members"],
|
queryKey: ["org", slug, "members"],
|
||||||
queryFn: () => api.orgs.members.list({ slug: slug! }),
|
queryFn: () => {
|
||||||
|
if (!slug) {
|
||||||
|
throw new Error("Slug is required");
|
||||||
|
}
|
||||||
|
return api.orgs.members.list({ slug });
|
||||||
|
},
|
||||||
enabled: !!slug,
|
enabled: !!slug,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fetch org sites
|
// Fetch org sites
|
||||||
const sitesQuery = createQuery(() => ({
|
const sitesQuery = createQuery(() => ({
|
||||||
queryKey: ["org", slug, "sites"],
|
queryKey: ["org", slug, "sites"],
|
||||||
queryFn: () => api.orgs.sites.list({ slug: slug! }),
|
queryFn: () => {
|
||||||
|
if (!slug) {
|
||||||
|
throw new Error("Slug is required");
|
||||||
|
}
|
||||||
|
return api.orgs.sites.list({ slug });
|
||||||
|
},
|
||||||
enabled: !!slug,
|
enabled: !!slug,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -56,18 +65,9 @@ const isOwner = $derived(currentUserRole === "owner");
|
|||||||
// Loading state
|
// Loading state
|
||||||
const isLoading = $derived(userQuery.isPending || membersQuery.isPending);
|
const isLoading = $derived(userQuery.isPending || membersQuery.isPending);
|
||||||
|
|
||||||
// Error state
|
// Error state (auth errors handled by root AuthGuard)
|
||||||
const error = $derived(!userQuery.error ? membersQuery.error : null);
|
const error = $derived(!userQuery.error ? membersQuery.error : null);
|
||||||
|
|
||||||
// Redirect to login on auth error
|
|
||||||
$effect(() => {
|
|
||||||
if (userQuery.error) {
|
|
||||||
goto(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provide context to child components
|
// Provide context to child components
|
||||||
setContext("orgContext", {
|
setContext("orgContext", {
|
||||||
get slug() {
|
get slug() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
prerender: {
|
prerender: {
|
||||||
handleHttpError: "warn",
|
handleHttpError: "warn",
|
||||||
|
handleUnseenRoutes: "ignore",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
7
bun.lock
7
bun.lock
@@ -48,11 +48,14 @@
|
|||||||
"name": "@reviq/cli",
|
"name": "@reviq/cli",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"reviq": "./dist/index.js",
|
"reviq": "./dist/reviq",
|
||||||
"__reviq_bash_complete": "./dist/bash-complete.js",
|
"__reviq_bash_complete": "./dist/bash-complete",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@orpc/client": "^1.13.2",
|
||||||
|
"@orpc/contract": "^1.13.2",
|
||||||
|
"@reviq/api-contract": "workspace:*",
|
||||||
"@reviq/db": "workspace:*",
|
"@reviq/db": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"@stricli/auto-complete": "^1.0.0",
|
"@stricli/auto-complete": "^1.0.0",
|
||||||
|
|||||||
@@ -2363,14 +2363,24 @@ _Depends on: J1-J6, C3_
|
|||||||
- Reusable components: `$lib/components/org/role-badge.svelte`, `confirm-dialog.svelte`
|
- Reusable components: `$lib/components/org/role-badge.svelte`, `confirm-dialog.svelte`
|
||||||
- Sidebar updated with "Organizations" nav item
|
- Sidebar updated with "Organizations" nav item
|
||||||
|
|
||||||
#### Workstream M: Admin Pages (Frontend)
|
#### Workstream M: Admin Pages (Frontend) ✅
|
||||||
|
|
||||||
_Depends on: K1-K5, C3_
|
_Depends on: K1-K5, C3_
|
||||||
_Can run parallel to L_
|
_Can run parallel to L_
|
||||||
|
|
||||||
- [ ] **M1**: Create `/admin` dashboard page
|
- [x] **M1**: Create `/admin` dashboard page
|
||||||
- [ ] **M2**: Create `/admin/orgs` pages (list, new, details)
|
- [x] **M2**: Create `/admin/orgs` pages (list, new, details)
|
||||||
- [ ] **M3**: Create `/admin/users` pages (list, details)
|
- [x] **M3**: Create `/admin/users` pages (list, details)
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
- Admin layout at `/routes/admin/+layout.svelte` provides superuser access control
|
||||||
|
- Redirects non-superusers to `/dashboard` with toast error
|
||||||
|
- Admin dashboard shows org/user counts with quick action links
|
||||||
|
- Org management: list all orgs, create new with owner email, view/edit details, manage sites
|
||||||
|
- User management: list all users, view details, toggle superuser status, confirm email
|
||||||
|
- Sidebar shows admin link (shield icon) only for superusers
|
||||||
|
- Reusable component: `$lib/components/admin/superuser-badge.svelte`
|
||||||
|
- All destructive actions use ConfirmDialog
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
317
docs/test-plans/admin.md
Normal file
317
docs/test-plans/admin.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# Test Plan: Admin Dashboard (Workstream M)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Manual UI test plan for superuser-only admin management pages:
|
||||||
|
- `/admin` - Admin dashboard
|
||||||
|
- `/admin/orgs` - Organization list
|
||||||
|
- `/admin/orgs/new` - Create organization
|
||||||
|
- `/admin/orgs/[slug]` - Organization details
|
||||||
|
- `/admin/users` - User list
|
||||||
|
- `/admin/users/[email]` - User details
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Dev server running: `bun run --cwd apps/publisher-dashboard dev`
|
||||||
|
- Test accounts:
|
||||||
|
- Superuser account (has `is_superuser = true`)
|
||||||
|
- Regular user account (not a superuser)
|
||||||
|
- At least one organization with sites
|
||||||
|
- At least one user who is not a superuser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Access Control
|
||||||
|
|
||||||
|
### 1.1 Superuser Access
|
||||||
|
- [ ] Superuser visiting `/admin` sees admin dashboard
|
||||||
|
- [ ] Superuser can access all admin sub-pages
|
||||||
|
|
||||||
|
### 1.2 Non-Superuser Access
|
||||||
|
- [ ] Regular user visiting `/admin` gets redirected to `/dashboard`
|
||||||
|
- [ ] Toast error message: "Access denied. Superuser privileges required."
|
||||||
|
- [ ] Regular user visiting `/admin/orgs` gets redirected
|
||||||
|
- [ ] Regular user visiting `/admin/users` gets redirected
|
||||||
|
|
||||||
|
### 1.3 Unauthenticated Access
|
||||||
|
- [ ] Unauthenticated user visiting `/admin` redirects to `/auth/login`
|
||||||
|
- [ ] After login as superuser, returns to `/admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Admin Dashboard (`/admin`)
|
||||||
|
|
||||||
|
### 2.1 Display
|
||||||
|
- [ ] Page title is "Admin Dashboard"
|
||||||
|
- [ ] Red "Admin" badge visible at top
|
||||||
|
- [ ] Summary cards display:
|
||||||
|
- Organizations card with correct count
|
||||||
|
- Users card with correct count
|
||||||
|
- [ ] Cards are clickable and navigate to respective list pages
|
||||||
|
|
||||||
|
### 2.2 Quick Actions
|
||||||
|
- [ ] "New Organization" button visible
|
||||||
|
- [ ] Button navigates to `/admin/orgs/new`
|
||||||
|
|
||||||
|
### 2.3 Loading States
|
||||||
|
- [ ] Loading spinner shows while fetching data
|
||||||
|
- [ ] Error state displays if API fails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Organization List (`/admin/orgs`)
|
||||||
|
|
||||||
|
### 3.1 Display
|
||||||
|
- [ ] Page title is "Organizations"
|
||||||
|
- [ ] Header shows "Organizations (count)" with correct count
|
||||||
|
- [ ] "New Organization" button visible in header
|
||||||
|
- [ ] Table displays all organizations (not just user's orgs)
|
||||||
|
|
||||||
|
### 3.2 Table Content
|
||||||
|
- [ ] Slug column displays org slug
|
||||||
|
- [ ] Display Name column shows org name
|
||||||
|
- [ ] Created At column shows formatted date
|
||||||
|
- [ ] Actions column has View and Delete buttons
|
||||||
|
|
||||||
|
### 3.3 View Action
|
||||||
|
- [ ] View button navigates to `/admin/orgs/[slug]`
|
||||||
|
|
||||||
|
### 3.4 Delete Action
|
||||||
|
- [ ] Delete button opens confirmation dialog
|
||||||
|
- [ ] Dialog shows org name and warning message
|
||||||
|
- [ ] Cancel button closes dialog without action
|
||||||
|
- [ ] Confirm button deletes organization
|
||||||
|
- [ ] Success toast: "Organization deleted"
|
||||||
|
- [ ] Org disappears from list after deletion
|
||||||
|
- [ ] Error toast on failure
|
||||||
|
|
||||||
|
### 3.5 Empty State
|
||||||
|
- [ ] Shows appropriate message when no organizations exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create Organization (`/admin/orgs/new`)
|
||||||
|
|
||||||
|
### 4.1 Display
|
||||||
|
- [ ] Page title is "New Organization"
|
||||||
|
- [ ] Back link "Back to organizations" works
|
||||||
|
|
||||||
|
### 4.2 Form Fields
|
||||||
|
- [ ] Slug input: accepts lowercase alphanumeric and hyphens
|
||||||
|
- [ ] Slug input: auto-converts uppercase to lowercase
|
||||||
|
- [ ] Slug input: strips invalid characters
|
||||||
|
- [ ] Display Name input: accepts any text
|
||||||
|
- [ ] Owner Email input: validates email format
|
||||||
|
|
||||||
|
### 4.3 Form Validation
|
||||||
|
- [ ] Submit button disabled when fields are empty
|
||||||
|
- [ ] Submit button enabled when all fields filled
|
||||||
|
- [ ] Form submits on button click
|
||||||
|
|
||||||
|
### 4.4 Submit Flow
|
||||||
|
- [ ] Loading state on submit button
|
||||||
|
- [ ] Success toast: "Organization created"
|
||||||
|
- [ ] Redirects to `/admin/orgs` on success
|
||||||
|
- [ ] Error toast on failure (e.g., slug already exists)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Organization Details (`/admin/orgs/[slug]`)
|
||||||
|
|
||||||
|
### 5.1 Header Section
|
||||||
|
- [ ] Org logo displays if set, placeholder icon otherwise
|
||||||
|
- [ ] Display name shown prominently
|
||||||
|
- [ ] Slug displayed
|
||||||
|
- [ ] Created date shown
|
||||||
|
|
||||||
|
### 5.2 Settings Card
|
||||||
|
- [ ] Display name input pre-filled with current value
|
||||||
|
- [ ] Logo URL input pre-filled if set
|
||||||
|
- [ ] Save button disabled when no changes
|
||||||
|
- [ ] Save button enabled when form is dirty
|
||||||
|
- [ ] Success toast on save: "Organization updated"
|
||||||
|
- [ ] Changes reflected after save
|
||||||
|
|
||||||
|
### 5.3 Sites Card
|
||||||
|
- [ ] Title shows "Sites (count)"
|
||||||
|
- [ ] Table shows all sites for the org
|
||||||
|
- [ ] Each site has domain and Remove button
|
||||||
|
|
||||||
|
### 5.4 Add Site
|
||||||
|
- [ ] Domain input visible
|
||||||
|
- [ ] Add button visible
|
||||||
|
- [ ] Adding valid domain shows success toast
|
||||||
|
- [ ] New site appears in list
|
||||||
|
- [ ] Error toast on invalid/duplicate domain
|
||||||
|
|
||||||
|
### 5.5 Remove Site
|
||||||
|
- [ ] Remove button opens confirmation dialog
|
||||||
|
- [ ] Dialog shows domain being removed
|
||||||
|
- [ ] Confirm removes site from list
|
||||||
|
- [ ] Success toast on removal
|
||||||
|
|
||||||
|
### 5.6 Danger Zone
|
||||||
|
- [ ] Card has red border styling
|
||||||
|
- [ ] Warning text about permanent deletion
|
||||||
|
- [ ] Delete button opens confirmation dialog
|
||||||
|
- [ ] Confirm deletes org and redirects to `/admin/orgs`
|
||||||
|
- [ ] Success toast on deletion
|
||||||
|
|
||||||
|
### 5.7 Navigation
|
||||||
|
- [ ] Back link works
|
||||||
|
- [ ] 404 error for non-existent org slug
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User List (`/admin/users`)
|
||||||
|
|
||||||
|
### 6.1 Display
|
||||||
|
- [ ] Page title is "Users"
|
||||||
|
- [ ] Header shows "Users (count)" with correct count
|
||||||
|
- [ ] Table displays all users in system
|
||||||
|
|
||||||
|
### 6.2 Table Content
|
||||||
|
- [ ] Email column displays user email
|
||||||
|
- [ ] Display Name column shows name (or "-" if not set)
|
||||||
|
- [ ] Email Verified column shows checkmark or X icon
|
||||||
|
- [ ] Superuser column shows SuperuserBadge for superusers
|
||||||
|
- [ ] Actions column has View button
|
||||||
|
|
||||||
|
### 6.3 View Action
|
||||||
|
- [ ] View button navigates to `/admin/users/[email]`
|
||||||
|
- [ ] Email is URL-encoded in the link
|
||||||
|
|
||||||
|
### 6.4 Empty State
|
||||||
|
- [ ] Shows appropriate message when no users exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User Details (`/admin/users/[email]`)
|
||||||
|
|
||||||
|
### 7.1 Header Section
|
||||||
|
- [ ] Avatar with initials displays
|
||||||
|
- [ ] Display name shown (or "Unknown" if not set)
|
||||||
|
- [ ] Email shown below name
|
||||||
|
- [ ] SuperuserBadge shown if user is superuser
|
||||||
|
|
||||||
|
### 7.2 Profile Info Card
|
||||||
|
- [ ] Email displayed (read-only)
|
||||||
|
- [ ] Display Name displayed
|
||||||
|
- [ ] Full Name displayed
|
||||||
|
- [ ] Phone Number displayed
|
||||||
|
- [ ] Email Verified status (Yes/No)
|
||||||
|
|
||||||
|
### 7.3 Permissions Card
|
||||||
|
- [ ] Superuser checkbox visible
|
||||||
|
- [ ] Checkbox reflects current status
|
||||||
|
- [ ] Save button disabled when no changes
|
||||||
|
- [ ] Save button enabled when checkbox changed
|
||||||
|
|
||||||
|
### 7.4 Toggle Superuser
|
||||||
|
- [ ] Can grant superuser to regular user
|
||||||
|
- [ ] Can revoke superuser from superuser (if not self)
|
||||||
|
- [ ] Success toast on save
|
||||||
|
- [ ] Cannot demote self (checkbox disabled when viewing own profile)
|
||||||
|
- [ ] Warning shown when viewing own profile
|
||||||
|
|
||||||
|
### 7.5 Actions Card
|
||||||
|
- [ ] "Confirm Email" button visible only if email not verified
|
||||||
|
- [ ] Hidden if email already verified
|
||||||
|
- [ ] Button confirms email on click
|
||||||
|
- [ ] Success toast: "Email confirmed"
|
||||||
|
- [ ] Button disappears after confirmation
|
||||||
|
|
||||||
|
### 7.6 Navigation
|
||||||
|
- [ ] Back link works
|
||||||
|
- [ ] 404 error for non-existent user email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sidebar Navigation
|
||||||
|
|
||||||
|
### 8.1 Admin Link
|
||||||
|
- [ ] Shield icon visible for superusers
|
||||||
|
- [ ] Hidden for regular users
|
||||||
|
- [ ] Tooltip shows "Admin" on hover
|
||||||
|
- [ ] Clicking navigates to `/admin`
|
||||||
|
- [ ] Active state (red tint) when on `/admin` routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cross-Cutting Concerns
|
||||||
|
|
||||||
|
### 9.1 Loading States
|
||||||
|
- [ ] All pages show loading spinner during data fetch
|
||||||
|
- [ ] Buttons show loading state during operations
|
||||||
|
|
||||||
|
### 9.2 Error Handling
|
||||||
|
- [ ] API errors display user-friendly messages
|
||||||
|
- [ ] Toast notifications for action results
|
||||||
|
- [ ] Error states don't crash the app
|
||||||
|
|
||||||
|
### 9.3 Responsive Design
|
||||||
|
- [ ] Pages render correctly on mobile viewport
|
||||||
|
- [ ] Tables scroll horizontally on small screens
|
||||||
|
- [ ] Forms stack vertically on mobile
|
||||||
|
|
||||||
|
### 9.4 Query Invalidation
|
||||||
|
- [ ] After org create: org list refreshes
|
||||||
|
- [ ] After org delete: org list refreshes
|
||||||
|
- [ ] After org update: org details refresh
|
||||||
|
- [ ] After add site: sites list refreshes
|
||||||
|
- [ ] After remove site: sites list refreshes
|
||||||
|
- [ ] After user update: user details refresh
|
||||||
|
- [ ] After confirm email: user details refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Edge Cases
|
||||||
|
|
||||||
|
### 10.1 Self-Demotion Prevention
|
||||||
|
- [ ] Cannot remove own superuser status
|
||||||
|
- [ ] Warning message explains why
|
||||||
|
|
||||||
|
### 10.2 Special Characters in Email
|
||||||
|
- [ ] User with `+` in email can be viewed
|
||||||
|
- [ ] User with `.` in email can be viewed
|
||||||
|
- [ ] Email properly URL-encoded/decoded
|
||||||
|
|
||||||
|
### 10.3 Long Content
|
||||||
|
- [ ] Long org names truncate or wrap properly
|
||||||
|
- [ ] Long email addresses don't break layout
|
||||||
|
- [ ] Long URLs in logo field don't break layout
|
||||||
|
|
||||||
|
### 10.4 Empty States
|
||||||
|
- [ ] Org with no sites shows "No sites" message
|
||||||
|
- [ ] Empty org list shows appropriate message
|
||||||
|
- [ ] Empty user list shows appropriate message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Matrix: Admin vs Non-Admin
|
||||||
|
|
||||||
|
| Feature | Superuser | Regular User |
|
||||||
|
|---------|-----------|--------------|
|
||||||
|
| View admin dashboard | Yes | Redirected |
|
||||||
|
| View org list | Yes | Redirected |
|
||||||
|
| Create organization | Yes | Redirected |
|
||||||
|
| View org details | Yes | Redirected |
|
||||||
|
| Edit org settings | Yes | Redirected |
|
||||||
|
| Manage org sites | Yes | Redirected |
|
||||||
|
| Delete organization | Yes | Redirected |
|
||||||
|
| View user list | Yes | Redirected |
|
||||||
|
| View user details | Yes | Redirected |
|
||||||
|
| Toggle superuser | Yes (not self) | Redirected |
|
||||||
|
| Confirm user email | Yes | Redirected |
|
||||||
|
| See admin link in sidebar | Yes | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Checklist
|
||||||
|
|
||||||
|
After any changes to admin pages, verify:
|
||||||
|
- [ ] Access control still redirects non-superusers
|
||||||
|
- [ ] All CRUD operations function
|
||||||
|
- [ ] Error states still display
|
||||||
|
- [ ] Navigation works end-to-end
|
||||||
|
- [ ] Sidebar admin link visibility correct
|
||||||
@@ -26,6 +26,8 @@ export interface BootstrapInput {
|
|||||||
tokenName?: string;
|
tokenName?: string;
|
||||||
/** Optional token expiration in days (defaults to 365) */
|
/** Optional token expiration in days (defaults to 365) */
|
||||||
tokenExpirationDays?: number;
|
tokenExpirationDays?: number;
|
||||||
|
/** Delete existing user and org if they exist (defaults to false) */
|
||||||
|
dangerouslyOverwriteExisting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,7 @@ export const executeBootstrap = async (
|
|||||||
orgDisplayName = "RevIQ",
|
orgDisplayName = "RevIQ",
|
||||||
tokenName = "CLI bootstrap token",
|
tokenName = "CLI bootstrap token",
|
||||||
tokenExpirationDays = 365,
|
tokenExpirationDays = 365,
|
||||||
|
dangerouslyOverwriteExisting = false,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
// Validate password length
|
// Validate password length
|
||||||
@@ -84,7 +87,84 @@ export const executeBootstrap = async (
|
|||||||
|
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
// Check if user already exists
|
// Handle overwrite mode - delete existing user and org
|
||||||
|
if (dangerouslyOverwriteExisting) {
|
||||||
|
// Delete existing user and related records
|
||||||
|
const existingUser = await trx
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("email", "=", normalizedEmail)
|
||||||
|
.select("id")
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// Delete all user-related records (FK constraints)
|
||||||
|
await trx
|
||||||
|
.deleteFrom("api_tokens")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("email_verifications")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("login_requests")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("passkeys")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("password_resets")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("sessions")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("user_devices")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_members")
|
||||||
|
.where("user_id", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
// Delete invites created by this user
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("invited_by", "=", existingUser.id)
|
||||||
|
.execute();
|
||||||
|
// Delete the user
|
||||||
|
await trx.deleteFrom("users").where("id", "=", existingUser.id).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing org and related records
|
||||||
|
const existingOrg = await trx
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", orgSlug)
|
||||||
|
.select("id")
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (existingOrg) {
|
||||||
|
// Delete all org-related records (FK constraints)
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("org_id", "=", existingOrg.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_members")
|
||||||
|
.where("org_id", "=", existingOrg.id)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_sites")
|
||||||
|
.where("org_id", "=", existingOrg.id)
|
||||||
|
.execute();
|
||||||
|
// Delete the org
|
||||||
|
await trx.deleteFrom("orgs").where("id", "=", existingOrg.id).execute();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if user already exists (normal mode)
|
||||||
const existing = await trx
|
const existing = await trx
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.where("email", "=", normalizedEmail)
|
.where("email", "=", normalizedEmail)
|
||||||
@@ -94,6 +174,7 @@ export const executeBootstrap = async (
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error(`User with email ${email} already exists`);
|
throw new Error(`User with email ${email} already exists`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|||||||
105
packages/utils/src/generate-base58-token.test.ts
Normal file
105
packages/utils/src/generate-base58-token.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
generateSecureBase58Token,
|
||||||
|
isBase58,
|
||||||
|
parseBase58Token,
|
||||||
|
} from "./generate-base58-token.js";
|
||||||
|
|
||||||
|
const BASE58_ALPHABET =
|
||||||
|
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
describe("isBase58", () => {
|
||||||
|
it("should return true for valid base58 strings", () => {
|
||||||
|
expect(isBase58("123456789")).toBe(true);
|
||||||
|
expect(isBase58("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isBase58("9JKmn")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for strings with invalid characters", () => {
|
||||||
|
// 0, O, I, l are not in base58 alphabet
|
||||||
|
expect(isBase58("0")).toBe(false);
|
||||||
|
expect(isBase58("O")).toBe(false);
|
||||||
|
expect(isBase58("I")).toBe(false);
|
||||||
|
expect(isBase58("l")).toBe(false);
|
||||||
|
expect(isBase58("abc0def")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for empty strings", () => {
|
||||||
|
expect(isBase58("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for strings with special characters", () => {
|
||||||
|
expect(isBase58("abc+def")).toBe(false);
|
||||||
|
expect(isBase58("abc/def")).toBe(false);
|
||||||
|
expect(isBase58("abc=def")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateSecureBase58Token", () => {
|
||||||
|
it("should generate a valid base58 token", () => {
|
||||||
|
const token = generateSecureBase58Token();
|
||||||
|
expect(isBase58(token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate tokens of expected length (~33 chars for 24 bytes)", () => {
|
||||||
|
const token = generateSecureBase58Token();
|
||||||
|
// 24 bytes = 192 bits, base58 encoding gives roughly 33 chars
|
||||||
|
expect(token.length).toBeGreaterThanOrEqual(30);
|
||||||
|
expect(token.length).toBeLessThanOrEqual(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate unique tokens", () => {
|
||||||
|
const tokens = new Set<string>();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
tokens.add(generateSecureBase58Token());
|
||||||
|
}
|
||||||
|
expect(tokens.size).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only use base58 alphabet characters", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const token = generateSecureBase58Token();
|
||||||
|
for (const char of token) {
|
||||||
|
expect(BASE58_ALPHABET.includes(char)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prepend prefix when provided", () => {
|
||||||
|
const token = generateSecureBase58Token("lrt_");
|
||||||
|
expect(token.startsWith("lrt_")).toBe(true);
|
||||||
|
// The part after prefix should be valid base58
|
||||||
|
expect(isBase58(token.slice(4))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBase58Token", () => {
|
||||||
|
it("should parse token with correct prefix", () => {
|
||||||
|
const token = generateSecureBase58Token("lrt_");
|
||||||
|
const parsed = parseBase58Token(token, "lrt_");
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
if (parsed !== null) {
|
||||||
|
expect(isBase58(parsed)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for wrong prefix", () => {
|
||||||
|
const token = generateSecureBase58Token("lrt_");
|
||||||
|
const parsed = parseBase58Token(token, "wrong_");
|
||||||
|
expect(parsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for missing prefix", () => {
|
||||||
|
const token = generateSecureBase58Token();
|
||||||
|
const parsed = parseBase58Token(token, "lrt_");
|
||||||
|
expect(parsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid base58 after prefix", () => {
|
||||||
|
const invalidToken = "lrt_abc0def"; // 0 is not valid base58
|
||||||
|
const parsed = parseBase58Token(invalidToken, "lrt_");
|
||||||
|
expect(parsed).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/utils/src/generate-base58-token.ts
Normal file
75
packages/utils/src/generate-base58-token.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Generate cryptographically secure base58 tokens
|
||||||
|
* Uses Bitcoin-style base58 alphabet (no 0, O, I, l to avoid confusion)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE58_ALPHABET =
|
||||||
|
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is valid base58
|
||||||
|
*/
|
||||||
|
export const isBase58 = (str: string): boolean => {
|
||||||
|
for (const char of str) {
|
||||||
|
if (!BASE58_ALPHABET.includes(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure base58 token
|
||||||
|
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
||||||
|
*
|
||||||
|
* @param prefix - Optional prefix to prepend (e.g., "login_" for login request tokens)
|
||||||
|
*/
|
||||||
|
export function generateSecureBase58Token<TPrefix extends string = "">(
|
||||||
|
prefix?: TPrefix,
|
||||||
|
): `${TPrefix}${string}` {
|
||||||
|
const byteLength = 24;
|
||||||
|
const bytes = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
|
// Convert bytes to base58
|
||||||
|
let result = "";
|
||||||
|
let num = BigInt(0);
|
||||||
|
for (const byte of bytes) {
|
||||||
|
num = num * 256n + BigInt(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (num > 0n) {
|
||||||
|
const remainder = Number(num % 58n);
|
||||||
|
result = BASE58_ALPHABET.charAt(remainder) + result;
|
||||||
|
num /= 58n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle leading zeros
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0) {
|
||||||
|
result = BASE58_ALPHABET.charAt(0) + result;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix ?? ""}${result}` as `${TPrefix}${string}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a token with an expected prefix
|
||||||
|
* Returns the token without prefix if valid, null if invalid
|
||||||
|
*/
|
||||||
|
export const parseBase58Token = (
|
||||||
|
token: string,
|
||||||
|
expectedPrefix: string,
|
||||||
|
): string | null => {
|
||||||
|
if (!token.startsWith(expectedPrefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base58Part = token.slice(expectedPrefix.length);
|
||||||
|
if (!isBase58(base58Part)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return base58Part;
|
||||||
|
};
|
||||||
@@ -13,8 +13,8 @@ describe("hashPassword", () => {
|
|||||||
expect(parts[1]).toBe("pbkdf2-sha256");
|
expect(parts[1]).toBe("pbkdf2-sha256");
|
||||||
expect(parts[2]).toBe("100000");
|
expect(parts[2]).toBe("100000");
|
||||||
// Salt and hash should be non-empty base64 strings
|
// Salt and hash should be non-empty base64 strings
|
||||||
expect(parts[3].length).toBeGreaterThan(0);
|
expect(parts[3]?.length).toBeGreaterThan(0);
|
||||||
expect(parts[4].length).toBeGreaterThan(0);
|
expect(parts[4]?.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should generate different hashes for the same password", async () => {
|
it("should generate different hashes for the same password", async () => {
|
||||||
@@ -98,4 +98,66 @@ describe("verifyPassword", () => {
|
|||||||
const wrongResult = await verifyPassword("password", hash);
|
const wrongResult = await verifyPassword("password", hash);
|
||||||
expect(wrongResult).toBe(false);
|
expect(wrongResult).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should verify known hash for 'password'", async () => {
|
||||||
|
// This hash was generated for the password "password"
|
||||||
|
const storedHash =
|
||||||
|
"$pbkdf2-sha256$100000$iUaDbbVm+Mf0HG7RcCCOzw==$IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
|
||||||
|
const password = "password";
|
||||||
|
|
||||||
|
const result = await verifyPassword(password, storedHash);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should derive consistent hash for known salt and password", async () => {
|
||||||
|
// Manually verify the derivation produces the expected hash
|
||||||
|
const password = "password";
|
||||||
|
const saltB64 = "iUaDbbVm+Mf0HG7RcCCOzw==";
|
||||||
|
const expectedHashB64 = "IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
|
||||||
|
const iterations = 100000;
|
||||||
|
|
||||||
|
// Decode salt
|
||||||
|
const saltBinary = atob(saltB64);
|
||||||
|
const salt = new Uint8Array(saltBinary.length);
|
||||||
|
for (let i = 0; i < saltBinary.length; i++) {
|
||||||
|
salt[i] = saltBinary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const passwordBuffer = encoder.encode(password);
|
||||||
|
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
passwordBuffer,
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveBits"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const derivedBits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
32 * 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encode result to base64
|
||||||
|
const derivedArray = new Uint8Array(derivedBits);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of derivedArray) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
const derivedB64 = btoa(binary);
|
||||||
|
|
||||||
|
console.log("Expected hash:", expectedHashB64);
|
||||||
|
console.log("Derived hash: ", derivedB64);
|
||||||
|
console.log("Match:", derivedB64 === expectedHashB64);
|
||||||
|
|
||||||
|
expect(derivedB64).toBe(expectedHashB64);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export const verifyPassword = async (
|
|||||||
const derivedBits = await crypto.subtle.deriveBits(
|
const derivedBits = await crypto.subtle.deriveBits(
|
||||||
{
|
{
|
||||||
name: "PBKDF2",
|
name: "PBKDF2",
|
||||||
|
// @ts-expect-error - salt is a Uint8Array
|
||||||
salt,
|
salt,
|
||||||
iterations,
|
iterations,
|
||||||
hash: "SHA-256",
|
hash: "SHA-256",
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
|
export {
|
||||||
|
generateSecureBase58Token,
|
||||||
|
isBase58,
|
||||||
|
parseBase58Token,
|
||||||
|
} from "./generate-base58-token.js";
|
||||||
export { hashPassword, verifyPassword } from "./hash-password.js";
|
export { hashPassword, verifyPassword } from "./hash-password.js";
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["@cloudflare/workers-types"]
|
"types": ["bun"]
|
||||||
},
|
}
|
||||||
"exclude": ["**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user