feat: implement basic authentication system
This commit is contained in:
@@ -154,6 +154,27 @@ backend:
|
||||
|
||||
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
||||
|
||||
### Optional Authentication
|
||||
|
||||
ExcaliDash can enforce a single username/password to protect the dashboard and API.
|
||||
Set these backend environment variables to enable it:
|
||||
|
||||
```bash
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=change-me
|
||||
# Recommended: keep sessions stable across restarts
|
||||
AUTH_SESSION_SECRET=your-random-secret
|
||||
# Optional (default: 168 hours)
|
||||
AUTH_SESSION_TTL_HOURS=168
|
||||
# Optional (default: excalidash_auth)
|
||||
AUTH_COOKIE_NAME=excalidash_auth
|
||||
# Optional: lax | strict | none (use "none" for cross-site hosting)
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
```
|
||||
|
||||
When enabled, the UI prompts for a login before accessing any drawings,
|
||||
and all API/WebSocket traffic requires the session cookie.
|
||||
|
||||
# Development
|
||||
|
||||
## Clone the Repository
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
PORT=8000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=file:/app/prisma/dev.db
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
# Optional auth cookie settings: lax | strict | none
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildAuthConfig,
|
||||
createAuthSessionToken,
|
||||
getAuthSessionFromCookie,
|
||||
validateAuthSessionToken,
|
||||
verifyCredentials,
|
||||
} from "../auth";
|
||||
|
||||
describe("Auth utilities", () => {
|
||||
it("disables auth when credentials are missing", () => {
|
||||
const config = buildAuthConfig({});
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies credentials and validates issued session tokens", () => {
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "super-secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
});
|
||||
|
||||
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true);
|
||||
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
const session = validateAuthSessionToken(config, token);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.username).toBe("admin");
|
||||
});
|
||||
|
||||
it("rejects expired session tokens", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
||||
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds
|
||||
});
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
|
||||
|
||||
expect(validateAuthSessionToken(config, token)).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extracts session tokens from cookies", () => {
|
||||
const config = buildAuthConfig({
|
||||
AUTH_USERNAME: "admin",
|
||||
AUTH_PASSWORD: "secret",
|
||||
AUTH_SESSION_SECRET: "test-secret",
|
||||
});
|
||||
|
||||
const token = createAuthSessionToken(config, "admin");
|
||||
const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
|
||||
const session = getAuthSessionFromCookie(cookieHeader, config);
|
||||
expect(session?.username).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export type AuthSameSite = "lax" | "strict" | "none";
|
||||
|
||||
export type AuthConfig = {
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
sessionTtlMs: number;
|
||||
cookieName: string;
|
||||
cookieSameSite: AuthSameSite;
|
||||
secret: Buffer;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
username: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SESSION_TTL_HOURS = 24 * 7;
|
||||
const DEFAULT_COOKIE_NAME = "excalidash_auth";
|
||||
const DEFAULT_COOKIE_SAMESITE: AuthSameSite = "lax";
|
||||
|
||||
const base64UrlEncode = (input: Buffer | string): string => {
|
||||
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const base64UrlDecode = (input: string): Buffer => {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64");
|
||||
};
|
||||
|
||||
const parseSessionTtlHours = (rawValue?: string): number => {
|
||||
if (!rawValue) return DEFAULT_SESSION_TTL_HOURS;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_SESSION_TTL_HOURS;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseSameSite = (rawValue?: string): AuthSameSite => {
|
||||
if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === "none" || normalized === "strict" || normalized === "lax") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_COOKIE_SAMESITE;
|
||||
};
|
||||
|
||||
const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
|
||||
if (!enabled) return Buffer.alloc(0);
|
||||
|
||||
const secretFromEnv = env.AUTH_SESSION_SECRET;
|
||||
if (secretFromEnv && secretFromEnv.trim().length > 0) {
|
||||
return Buffer.from(secretFromEnv, "utf8");
|
||||
}
|
||||
|
||||
const generated = crypto.randomBytes(32);
|
||||
const envLabel = env.NODE_ENV ? ` (${env.NODE_ENV})` : "";
|
||||
console.warn(
|
||||
`[security] AUTH_SESSION_SECRET is not set${envLabel}. ` +
|
||||
"Using an ephemeral per-process secret. Sessions will be invalidated on restart."
|
||||
);
|
||||
return generated;
|
||||
};
|
||||
|
||||
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
|
||||
const username = (env.AUTH_USERNAME || "").trim();
|
||||
const password = env.AUTH_PASSWORD || "";
|
||||
const enabled = username.length > 0 && password.length > 0;
|
||||
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
|
||||
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
|
||||
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
username,
|
||||
password,
|
||||
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
|
||||
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
|
||||
cookieSameSite,
|
||||
secret: resolveAuthSecret(enabled, env),
|
||||
};
|
||||
};
|
||||
|
||||
const signToken = (secret: Buffer, payloadB64: string): Buffer =>
|
||||
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
|
||||
|
||||
const safeCompare = (left: string, right: string): boolean => {
|
||||
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest();
|
||||
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest();
|
||||
return crypto.timingSafeEqual(leftHash, rightHash);
|
||||
};
|
||||
|
||||
export const verifyCredentials = (
|
||||
config: AuthConfig,
|
||||
inputUsername: string,
|
||||
inputPassword: string
|
||||
): boolean => {
|
||||
if (!config.enabled) return false;
|
||||
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
|
||||
};
|
||||
|
||||
export const createAuthSessionToken = (config: AuthConfig, username: string): string => {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Authentication is not enabled.");
|
||||
}
|
||||
|
||||
const issuedAt = Date.now();
|
||||
const payload: AuthSession = {
|
||||
username,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + config.sessionTtlMs,
|
||||
};
|
||||
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadB64 = base64UrlEncode(payloadJson);
|
||||
const sigB64 = base64UrlEncode(signToken(config.secret, payloadB64));
|
||||
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
};
|
||||
|
||||
export const validateAuthSessionToken = (
|
||||
config: AuthConfig,
|
||||
token: string | undefined | null
|
||||
): AuthSession | null => {
|
||||
if (!config.enabled || !token || typeof token !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [payloadB64, sigB64] = parts;
|
||||
try {
|
||||
const expectedSig = signToken(config.secret, payloadB64);
|
||||
const providedSig = base64UrlDecode(sigB64);
|
||||
if (providedSig.length !== expectedSig.length) return null;
|
||||
if (!crypto.timingSafeEqual(providedSig, expectedSig)) return null;
|
||||
|
||||
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
|
||||
const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
|
||||
if (
|
||||
typeof payload.username !== "string" ||
|
||||
typeof payload.iat !== "number" ||
|
||||
typeof payload.exp !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > payload.exp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload as AuthSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseCookieHeader = (
|
||||
cookieHeader: string | undefined
|
||||
): Record<string, string> => {
|
||||
if (!cookieHeader) return {};
|
||||
return cookieHeader.split(";").reduce<Record<string, string>>((acc, part) => {
|
||||
const [rawKey, ...rest] = part.trim().split("=");
|
||||
if (!rawKey) return acc;
|
||||
const value = rest.join("=");
|
||||
acc[decodeURIComponent(rawKey)] = decodeURIComponent(value || "");
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getAuthSessionFromCookie = (
|
||||
cookieHeader: string | undefined,
|
||||
config: AuthConfig
|
||||
): AuthSession | null => {
|
||||
if (!config.enabled) return null;
|
||||
const cookies = parseCookieHeader(cookieHeader);
|
||||
const token = cookies[config.cookieName];
|
||||
return validateAuthSessionToken(config, token);
|
||||
};
|
||||
|
||||
export const buildAuthCookieOptions = (
|
||||
secure: boolean,
|
||||
sameSite: AuthSameSite,
|
||||
maxAgeMs?: number
|
||||
) => {
|
||||
const normalizedSameSite = sameSite === "none" ? "none" : sameSite;
|
||||
const options: {
|
||||
httpOnly: boolean;
|
||||
sameSite: AuthSameSite;
|
||||
secure: boolean;
|
||||
path: string;
|
||||
maxAge?: number;
|
||||
} = {
|
||||
httpOnly: true,
|
||||
sameSite: normalizedSameSite,
|
||||
secure: normalizedSameSite === "none" ? true : secure,
|
||||
path: "/",
|
||||
};
|
||||
if (typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs > 0) {
|
||||
options.maxAge = maxAgeMs;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
+141
-1
@@ -23,6 +23,13 @@ import {
|
||||
getCsrfTokenHeader,
|
||||
getOriginFromReferer,
|
||||
} from "./security";
|
||||
import {
|
||||
buildAuthConfig,
|
||||
buildAuthCookieOptions,
|
||||
createAuthSessionToken,
|
||||
getAuthSessionFromCookie,
|
||||
verifyCredentials,
|
||||
} from "./auth";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -96,6 +103,13 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
|
||||
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
||||
console.log("Allowed origins:", allowedOrigins);
|
||||
|
||||
const authConfig = buildAuthConfig();
|
||||
if (authConfig.enabled) {
|
||||
console.log(`[auth] Enabled for user "${authConfig.username}".`);
|
||||
} else {
|
||||
console.log("[auth] Disabled (AUTH_USERNAME/AUTH_PASSWORD not set).");
|
||||
}
|
||||
|
||||
const uploadDir = path.resolve(__dirname, "../uploads");
|
||||
|
||||
const moveFile = async (source: string, destination: string) => {
|
||||
@@ -374,6 +388,52 @@ app.get("/csrf-token", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const isRequestSecure = (req: express.Request): boolean => {
|
||||
if (req.secure) return true;
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (Array.isArray(forwardedProto)) {
|
||||
return forwardedProto[0] === "https";
|
||||
}
|
||||
return forwardedProto === "https";
|
||||
};
|
||||
|
||||
const authExemptPaths = new Set([
|
||||
"/csrf-token",
|
||||
"/health",
|
||||
"/auth/status",
|
||||
"/auth/login",
|
||||
"/auth/logout",
|
||||
]);
|
||||
|
||||
const authMiddleware = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
if (!authConfig.enabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (authExemptPaths.has(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
res.locals.authUser = session.username;
|
||||
next();
|
||||
};
|
||||
|
||||
// CSRF validation middleware for state-changing requests
|
||||
const csrfProtectionMiddleware = (
|
||||
req: express.Request,
|
||||
@@ -438,9 +498,75 @@ const csrfProtectionMiddleware = (
|
||||
next();
|
||||
};
|
||||
|
||||
// Apply CSRF protection to all routes
|
||||
// Apply authentication and CSRF protection to all routes
|
||||
app.use(authMiddleware);
|
||||
app.use(csrfProtectionMiddleware);
|
||||
|
||||
const authLoginSchema = z.object({
|
||||
username: z.string().trim().min(1).max(200),
|
||||
password: z.string().min(1).max(512),
|
||||
});
|
||||
|
||||
app.get("/auth/status", (req, res) => {
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json({
|
||||
enabled: authConfig.enabled,
|
||||
authenticated: Boolean(session),
|
||||
user: session ? { username: session.username } : null,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/login", (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Authentication disabled",
|
||||
message: "Authentication is not enabled on this server.",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = authLoginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid payload",
|
||||
message: "Username and password are required.",
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
if (!verifyCredentials(authConfig, username, password)) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password.",
|
||||
});
|
||||
}
|
||||
|
||||
const token = createAuthSessionToken(authConfig, authConfig.username);
|
||||
res.cookie(
|
||||
authConfig.cookieName,
|
||||
token,
|
||||
buildAuthCookieOptions(
|
||||
isRequestSecure(req),
|
||||
authConfig.cookieSameSite,
|
||||
authConfig.sessionTtlMs
|
||||
)
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: { username: authConfig.username },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
res.clearCookie(
|
||||
authConfig.cookieName,
|
||||
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({ authenticated: false });
|
||||
});
|
||||
|
||||
const filesFieldSchema = z
|
||||
.union([z.record(z.string(), z.any()), z.null()])
|
||||
.optional()
|
||||
@@ -630,6 +756,20 @@ interface User {
|
||||
|
||||
const roomUsers = new Map<string, User[]>();
|
||||
|
||||
if (authConfig.enabled) {
|
||||
io.use((socket, next) => {
|
||||
const session = getAuthSessionFromCookie(
|
||||
socket.request.headers.cookie,
|
||||
authConfig
|
||||
);
|
||||
if (!session) {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
(socket as { authUser?: string }).authUser = session.username;
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
socket.on(
|
||||
"join-room",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect width="10" height="10" fill="#4f46e5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 140 B |
@@ -4,7 +4,14 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const FRONTEND_PORT = 5173;
|
||||
const BACKEND_PORT = 8000;
|
||||
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
|
||||
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
|
||||
const BACKEND_URL = process.env.API_URL || http://localhost:${BACKEND_PORT}`;
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
|
||||
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || "e2e-auth-secret";
|
||||
|
||||
process.env.AUTH_USERNAME = AUTH_USERNAME;
|
||||
process.env.AUTH_PASSWORD = AUTH_PASSWORD;
|
||||
process.env.AUTH_SESSION_SECRET = AUTH_SESSION_SECRET;
|
||||
|
||||
/**
|
||||
* Playwright configuration for E2E browser testing
|
||||
@@ -97,6 +104,9 @@ export default defineConfig({
|
||||
DATABASE_URL: "file:./dev.db",
|
||||
FRONTEND_URL,
|
||||
CSRF_MAX_REQUESTS: "1000",
|
||||
AUTH_USERNAME,
|
||||
AUTH_PASSWORD,
|
||||
AUTH_SESSION_SECRET,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("should require login and allow logout", async ({ page }) => {
|
||||
const username = process.env.AUTH_USERNAME || "admin";
|
||||
const password = process.env.AUTH_PASSWORD || "admin";
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Username").fill(username);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
// Wait for the dashboard to fully load (login updates state, no URL change)
|
||||
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.goto("/settings");
|
||||
const logoutButton = page.getByRole("button", { name: /Log out/i });
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensurePageAuthenticated } from "./helpers/auth";
|
||||
import {
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
@@ -42,6 +43,8 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
try {
|
||||
// Both users navigate to the same drawing
|
||||
await ensurePageAuthenticated(page1);
|
||||
await ensurePageAuthenticated(page2);
|
||||
await page1.goto(`/editor/${drawing.id}`);
|
||||
await page2.goto(`/editor/${drawing.id}`);
|
||||
|
||||
@@ -88,6 +91,8 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
try {
|
||||
// Both users navigate to the same drawing
|
||||
await ensurePageAuthenticated(page1);
|
||||
await ensurePageAuthenticated(page2);
|
||||
await page1.goto(`/editor/${drawing.id}`);
|
||||
await page2.goto(`/editor/${drawing.id}`);
|
||||
|
||||
@@ -190,6 +195,8 @@ test.describe("Real-time Collaboration", () => {
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
try {
|
||||
await ensurePageAuthenticated(page1);
|
||||
await ensurePageAuthenticated(page2);
|
||||
await page1.goto(`/editor/${drawing.id}`);
|
||||
await page2.goto(`/editor/${drawing.id}`);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import {
|
||||
API_URL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
|
||||
|
||||
export const test = base;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to root to check if we need to login
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// If we see the login page, perform login
|
||||
const loginText = page.getByText("Sign in to access your drawings");
|
||||
if (await loginText.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await page.getByLabel("Username").fill(AUTH_USERNAME);
|
||||
await page.getByLabel("Password").fill(AUTH_PASSWORD);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
// Wait for dashboard to load
|
||||
await page.getByPlaceholder("Search drawings...").waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -5,6 +5,58 @@ const DEFAULT_BACKEND_PORT = 8000;
|
||||
|
||||
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
|
||||
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
|
||||
|
||||
// Track authenticated API contexts
|
||||
const authenticatedContexts = new WeakSet<APIRequestContext>();
|
||||
|
||||
/**
|
||||
* Ensure the API request context is authenticated
|
||||
*/
|
||||
export async function ensureAuthenticated(request: APIRequestContext): Promise<void> {
|
||||
if (authenticatedContexts.has(request)) return;
|
||||
|
||||
// Check current auth status
|
||||
const statusResp = await request.get(`${API_URL}/auth/status`);
|
||||
if (!statusResp.ok()) {
|
||||
throw new Error(`Failed to check auth status: ${statusResp.status()}`);
|
||||
}
|
||||
|
||||
const status = (await statusResp.json()) as { enabled: boolean; authenticated: boolean };
|
||||
|
||||
if (!status.enabled) {
|
||||
// Auth is disabled, mark as "authenticated"
|
||||
authenticatedContexts.add(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.authenticated) {
|
||||
authenticatedContexts.add(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to login
|
||||
const csrfHeaders = await getCsrfHeaders(request);
|
||||
const loginResp = await request.post(`${API_URL}/auth/login`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...csrfHeaders,
|
||||
},
|
||||
data: {
|
||||
username: AUTH_USERNAME,
|
||||
password: AUTH_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
if (!loginResp.ok()) {
|
||||
const text = await loginResp.text();
|
||||
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
|
||||
}
|
||||
|
||||
authenticatedContexts.add(request);
|
||||
}
|
||||
|
||||
type CsrfTokenResponse = {
|
||||
token: string;
|
||||
header?: string;
|
||||
@@ -137,6 +189,8 @@ export async function createDrawing(
|
||||
request: APIRequestContext,
|
||||
overrides: CreateDrawingOptions = {}
|
||||
): Promise<DrawingRecord> {
|
||||
await ensureAuthenticated(request);
|
||||
|
||||
const payload = { ...defaultDrawingPayload(), ...overrides };
|
||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||
|
||||
@@ -169,6 +223,7 @@ export async function getDrawing(
|
||||
request: APIRequestContext,
|
||||
id: string
|
||||
): Promise<DrawingRecord> {
|
||||
await ensureAuthenticated(request);
|
||||
const response = await request.get(`${API_URL}/drawings/${id}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as DrawingRecord;
|
||||
@@ -178,6 +233,7 @@ export async function deleteDrawing(
|
||||
request: APIRequestContext,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await ensureAuthenticated(request);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
|
||||
@@ -202,6 +258,7 @@ export async function listDrawings(
|
||||
request: APIRequestContext,
|
||||
options: ListDrawingsOptions = {}
|
||||
): Promise<DrawingRecord[]> {
|
||||
await ensureAuthenticated(request);
|
||||
const params = new URLSearchParams();
|
||||
if (options.search) params.set("search", options.search);
|
||||
if (options.collectionId !== undefined) {
|
||||
@@ -224,6 +281,7 @@ export async function createCollection(
|
||||
request: APIRequestContext,
|
||||
name: string
|
||||
): Promise<CollectionRecord> {
|
||||
await ensureAuthenticated(request);
|
||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||
|
||||
let response = await request.post(`${API_URL}/collections`, {
|
||||
@@ -249,6 +307,7 @@ export async function createCollection(
|
||||
export async function listCollections(
|
||||
request: APIRequestContext
|
||||
): Promise<CollectionRecord[]> {
|
||||
await ensureAuthenticated(request);
|
||||
const response = await request.get(`${API_URL}/collections`);
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as CollectionRecord[];
|
||||
@@ -258,6 +317,7 @@ export async function deleteCollection(
|
||||
request: APIRequestContext,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await ensureAuthenticated(request);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { APIRequestContext, Page } from "@playwright/test";
|
||||
import { API_URL, getCsrfHeaders } from "./api";
|
||||
|
||||
type AuthStatus = {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
};
|
||||
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
|
||||
|
||||
const authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
|
||||
|
||||
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
|
||||
const cached = authStatusCache.get(request);
|
||||
// Only use cache if we're already authenticated
|
||||
if (cached?.authenticated) return cached;
|
||||
|
||||
const response = await request.get(`${API_URL}/auth/status`);
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AuthStatus;
|
||||
authStatusCache.set(request, data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
|
||||
authStatusCache.set(request, status);
|
||||
};
|
||||
|
||||
export const ensureApiAuthenticated = async (request: APIRequestContext) => {
|
||||
const status = await fetchAuthStatus(request);
|
||||
if (!status.enabled || status.authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = await getCsrfHeaders(request);
|
||||
const response = await request.post(`${API_URL}/auth/login`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
data: {
|
||||
username: AUTH_USERNAME,
|
||||
password: AUTH_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to authenticate test session: ${response.status()} ${text}`);
|
||||
}
|
||||
|
||||
setAuthStatus(request, { enabled: true, authenticated: true });
|
||||
};
|
||||
|
||||
export const ensurePageAuthenticated = async (page: Page) => {
|
||||
await ensureApiAuthenticated(page.request);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* E2E Tests for Theme Toggle functionality
|
||||
|
||||
+16
-10
@@ -4,20 +4,26 @@ import { Editor } from './pages/Editor';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { UploadProvider } from './context/UploadContext';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { AuthGate } from './components/AuthGate';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<UploadProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</UploadProvider>
|
||||
<AuthProvider>
|
||||
<UploadProvider>
|
||||
<Router>
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
</Router>
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,21 @@ export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export type AuthStatus = {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
user: { username: string } | null;
|
||||
};
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
|
||||
export const setUnauthorizedHandler = (handler: (() => void) | null) => {
|
||||
unauthorizedHandler = handler;
|
||||
};
|
||||
|
||||
// CSRF Token Management
|
||||
let csrfToken: string | null = null;
|
||||
let csrfHeaderName: string = "x-csrf-token";
|
||||
@@ -18,7 +31,8 @@ let csrfTokenPromise: Promise<void> | null = null;
|
||||
export const fetchCsrfToken = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await axios.get<{ token: string; header: string }>(
|
||||
`${API_URL}/csrf-token`
|
||||
`${API_URL}/csrf-token`,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
csrfToken = response.data.token;
|
||||
csrfHeaderName = response.data.header || "x-csrf-token";
|
||||
@@ -50,6 +64,11 @@ export const clearCsrfToken = (): void => {
|
||||
csrfToken = null;
|
||||
};
|
||||
|
||||
export const getCsrfHeaders = async (): Promise<Record<string, string>> => {
|
||||
await ensureCsrfToken();
|
||||
return csrfToken ? { [csrfHeaderName]: csrfToken } : {};
|
||||
};
|
||||
|
||||
// Add request interceptor to include CSRF token
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
@@ -70,6 +89,10 @@ api.interceptors.request.use(
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
|
||||
// If we get a 403 with CSRF error, clear token and retry once
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
@@ -92,6 +115,24 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
export const getAuthStatus = async (): Promise<AuthStatus> => {
|
||||
const response = await api.get<AuthStatus>("/auth/status");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const login = async (username: string, password: string) => {
|
||||
const response = await api.post<{ authenticated: boolean }>("/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
const response = await api.post<{ authenticated: boolean }>("/auth/logout");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const coerceTimestamp = (value: string | number | Date): number => {
|
||||
if (typeof value === "number") return value;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Login } from "../pages/Login";
|
||||
|
||||
export const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { state } = useAuth();
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
<p className="text-sm font-semibold">Loading session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.statusError) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200 px-4 text-center">
|
||||
<p className="text-sm font-semibold mb-2">{state.statusError}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-neutral-400">Please refresh to try again.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.enabled && !state.authenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as api from "../api";
|
||||
|
||||
type AuthState = {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
user: { username: string } | null;
|
||||
loading: boolean;
|
||||
statusError: string | null;
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
state: AuthState;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
enabled: false,
|
||||
authenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
statusError: null,
|
||||
});
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
}));
|
||||
try {
|
||||
const status = await api.getAuthStatus();
|
||||
setState({
|
||||
enabled: status.enabled,
|
||||
authenticated: status.authenticated,
|
||||
user: status.user,
|
||||
loading: false,
|
||||
statusError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth status:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
statusError: prev.statusError || "Unable to reach authentication service.",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
api.setUnauthorizedHandler(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authenticated: false,
|
||||
user: null,
|
||||
}));
|
||||
});
|
||||
return () => api.setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
await api.login(username, password);
|
||||
await refreshStatus();
|
||||
},
|
||||
[refreshStatus]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout();
|
||||
await refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
login,
|
||||
logout,
|
||||
refreshStatus,
|
||||
}),
|
||||
[state, login, logout, refreshStatus]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -114,6 +114,7 @@ export const Editor: React.FC = () => {
|
||||
const socket = io(socketUrl, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
} catch (err) {
|
||||
console.error("Login failed:", err);
|
||||
setError("Invalid username or password.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 px-4 py-12 transition-colors duration-200">
|
||||
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-3xl border-2 border-black dark:border-neutral-700 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Logo className="h-16 w-16 mb-4" />
|
||||
<h1 className="text-4xl text-slate-900 dark:text-white" style={{ fontFamily: "Excalifont" }}>
|
||||
ExcaliDash
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
|
||||
Sign in to access your drawings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white px-4 py-3 text-base font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] transition-all duration-200 hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user