Add admin CLI command and auth guard, use oRPC client
CLI changes: - Use official oRPC client instead of manual HTTP requests - Add admin complete-login command for dev workflow - Remove type assertions, use proper ContractRouterClient typing - Add @orpc/client and @orpc/contract dependencies API changes: - Use oRPC cookie helpers from @orpc/server/helpers - Improve admin complete-login error messages (expired, already completed) Dashboard changes: - Add AuthGuard component to redirect unauthenticated users to /auth/login - Update confirm page with correct CLI command and copy button - Remove duplicate auth redirect from dashboard layout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,24 +9,40 @@ 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 [${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:");
|
||||||
|
|||||||
@@ -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,18 @@ 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 +26,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,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";
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 +16,17 @@ 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 +165,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";
|
||||||
|
|
||||||
@@ -66,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() {
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user