Ctrl K
ComunidadGuías…

frontend/src/skins/accountLogic.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Account state shared by every skin's extension.
 *
 * The four skins render accounts in four different idioms, but they all talk to
 * the same endpoints and hold the same state. Keeping that here means a change
 * to the flow — a new field, a different error — happens once instead of four
 * times, which is the only way "every theme offers the same features" survives
 * contact with maintenance.
 *
 * Deliberately framework-light: the ported skins are class components generated
 * from their design sources, so this is a plain object they can drive from
 * `setState`, not a hook.
 */

import { usesClerkAccounts, configuredSocialProviders } from "@/lib/auth-mode";
import { isClerkEnabled } from "@/lib/clerk/instance";
import type { ApiActivityData } from "@/lib/api-activity";
import { clerkAccountApi, SOCIAL_PROVIDERS, type SocialProvider, type SignInResult, type PasswordResetResult } from "@/lib/clerk/accountApi";

export interface ApiKey {
    key_id: string;
    label: string;
    created_at: string;
    last_used_at: string | null;
    revoked: boolean;
}

export interface AccountUser {
    id: string;
    email: string;
    username?: string;
    password_enabled?: boolean;
    created_at: string;
}

/**
 * Password strength, without a dependency.
 *
 * Deliberately length-led. Composition rules ("one capital, one symbol") push
 * people toward Password1! and are not what makes a password hard to guess;
 * length and unpredictability are. This scores what actually matters and says
 * so in the hint, rather than demanding character classes.
 */
export interface Strength {
    score: 0 | 1 | 2 | 3 | 4;
    label: string;
    hint: string;
}

/**
 * The shortest password this deployment will accept.
 *
 * Clerk enforces its own floor server-side (12 in the configured PrivaTools instance), so a
 * UI that promised less would cheerfully accept a password and then be
 * refused. Local auth keeps the 10 it always had.
 */
/**
 * What the account page says about where your credentials go.
 *
 * A factual claim about privacy, and it stops being true the moment identity
 * moves to Clerk: there *is* an email, there *is* a reset link, and the
 * password is checked by somebody else's server. Leaving the old wording in
 * place would be the worst outcome of this migration — worse than the
 * migration itself — so the copy follows the backing store, from one place
 * that all four themes read.
 */
export const ACCOUNT_COPY = usesClerkAccounts()
    ? {
        storageHeading: "Clerk holds the sign-in.",
        storage:
            "Clerk manages your email, username and sign-in methods. PrivaTools never receives your account password or biometric data.",
        recovery:
            "Forgot your password? Reset it with an email. There is no recovery code to save. Your file tools are always available without an account.",
    }
    : {
        storageHeading: "We store an email and a hash.",
        storage:
            "A scrypt hash of your password, never the password itself. "
            + "Deleting your account removes both, and every key, immediately.",
        recovery:
            "We send no email — not even a reset link. Instead you get a recovery code "
            + "at signup. It is the only way back in, so keep it somewhere safe.",
    };

/**
 * Social providers to offer, or empty when this deployment has no Clerk.
 *
 * Empty is the meaningful case: local auth has no way to authenticate against
 * Google, so the buttons must not be rendered at all rather than rendered and
 * throwing.
 */
/** Only offer social connections that have been enabled in the Clerk dashboard. */
const CONFIGURED_SOCIAL = configuredSocialProviders();

export const SOCIAL_SIGN_IN = isClerkEnabled()
    ? SOCIAL_PROVIDERS.filter((p) => CONFIGURED_SOCIAL.includes(p.id))
    : [];

/**
 * Whether password recovery goes through an emailed code (Clerk) or the
 * recovery code issued at signup (local auth). Markup branches on this —
 * the two flows ask for different things in a different order.
 */
export const EMAIL_RESET = usesClerkAccounts();
export type { SocialProvider };

export const MIN_PASSWORD_LENGTH = usesClerkAccounts() ? 12 : 10;

const COMMON = [
    "password", "qwerty", "letmein", "welcome", "admin", "iloveyou",
    "123456", "12345678", "abc123", "monkey", "dragon", "football",
];

export function strengthOf(password: string): Strength {
    const pw = password ?? "";
    // The floor tracks whoever is actually going to check it. Clerk enforces
    // its own server-side minimum, and a form that promised less would accept
    // a password and then be refused — which reads as a bug, not a rule.
    const min = MIN_PASSWORD_LENGTH;
    if (!pw) return { score: 0, label: "", hint: `At least ${min} characters.` };

    const lower = pw.toLowerCase();
    if (COMMON.some((c) => lower.includes(c))) {
        return { score: 0, label: "Too easy to guess", hint: "That contains a very common password." };
    }
    if (pw.length < min) {
        const short = min - pw.length;
        return { score: 0, label: "Too short", hint: `${short} more character${short === 1 ? "" : "s"} needed.` };
    }

    // Length does most of the work; variety is a modest bonus.
    const variety = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z0-9]/].filter((r) => r.test(pw)).length;
    const unique = new Set(pw).size;
    let score = 1;
    if (pw.length >= 14) score += 1;
    if (pw.length >= 20) score += 1;
    if (variety >= 3 || unique >= 12) score += 1;

    const capped = Math.min(4, score) as 1 | 2 | 3 | 4;
    const labels: Record<number, string> = {
        1: "Weak", 2: "Fair", 3: "Good", 4: "Strong",
    };
    const hints: Record<number, string> = {
        1: "Longer is the easiest way to make it stronger.",
        2: "A few more words would help.",
        3: "Good. A longer passphrase would be better still.",
        4: "Hard to guess. Keep it somewhere you will not lose it.",
    };
    return { score: capped, label: labels[capped], hint: hints[capped] };
}

export interface AccountState {
    mode: "signin" | "signup" | "recover";
    email: string;
    username: string;
    signInVerification: "" | "email_code" | "totp";
    verificationDestination: string;
    password: string;
    busy: boolean;
    error: string;
    user: AccountUser | null;
    keys: ApiKey[];
    /** Shown once, immediately after creation. Never retrievable again. */
    freshKey: string;
    freshKeyCopied: boolean;
    keysLoading: boolean;
    /** Delete needs a second press; this is the armed state. */
    confirmingDelete: boolean;
    /** Shown once, right after signup. There is no email to resend it to. */
    recoveryCode: string;
    /** The user has confirmed they saved the recovery code. */
    recoverySaved: boolean;
    /** Password field visibility — typing a long passphrase blind is hostile. */
    showPassword: boolean;
    /** Signing in, creating an account, or recovering one. */
    recoveryInput: string;
    /** The "replace my recovery code" form is open. */
    rotating: boolean;
    rotatePassword: string;
    /**
     * Clerk asked for the code it emailed before finishing sign-up.
     *
     * Local auth had no such step — it issued a recovery code and was done —
     * so this is only ever set when Clerk is the backing store. Treating it as
     * ordinary state rather than a separate mode keeps the four skins from
     * each growing a branch.
     */
    needsEmailCode: boolean;
    emailCode: string;
    /** Clerk recover only: the reset code has been emailed; show stage two. */
    resetEmailSent: boolean;
    /** clerk-js never loaded — an extension is blocking it. Nothing here works. */
    blocked: boolean;
}

export const initialAccountState: AccountState = {
    mode: "signin", email: "", username: "", signInVerification: "", verificationDestination: "", password: "", busy: false, error: "",
    user: null, keys: [], freshKey: "", freshKeyCopied: false, keysLoading: false, confirmingDelete: false,
    recoveryCode: "", recoverySaved: false, showPassword: false, recoveryInput: "",
    rotating: false, rotatePassword: "",
    needsEmailCode: false, emailCode: "", resetEmailSent: false, blocked: false,
};

// Native sessions deliberately stay on this origin. The deployment proxies
// /api here; its optional upload/API subdomain does not allow cookie credentials.
const BASE = "/api";

async function call<T>(path: string, init?: RequestInit): Promise<T> {
    const res = await fetch(`${BASE}${path}`, {
        ...init,
        headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
        credentials: "same-origin",
        cache: "no-store",
    });
    let body: unknown = null;
    try { body = await res.json(); } catch { /* empty or non-JSON */ }
    if (!res.ok) {
        const detail = (body as { detail?: unknown } | null)?.detail;
        const message = typeof detail === "string" ? detail : Array.isArray(detail)
            ? detail.map(item => typeof item?.msg === "string" ? item.msg : "").filter(Boolean).join(". ")
            : "";
        throw new Error(message || `Request failed (${res.status})`);
    }
    return body as T;
}

const localAccountApi = {
    me: () => call<{ user: AccountUser }>("/auth/me"),
    register: async (email: string, password: string) => {
        const res = await call<{ user: AccountUser; recovery_code: string }>(
            "/auth/register",
            { method: "POST", body: JSON.stringify({ email, password }) },
        );
        // Local sign-up is always finished in one step — there is no email to
        // verify against. Reported in the same shape as Clerk's so callers
        // branch on the status rather than on which backend they got.
        return { status: "complete" as const, ...res };
    },
    recover: (email: string, recoveryCode: string, newPassword: string) =>
        call<{ ok: true; recovery_code: string }>("/auth/recover", {
            method: "POST",
            body: JSON.stringify({ email, recovery_code: recoveryCode, new_password: newPassword }),
        }),
    /** Issue a fresh recovery code while signed in, replacing a mislaid one. */
    rotateRecovery: (currentPassword: string) =>
        call<{ ok: true; recovery_code: string }>("/auth/recovery-code", {
            method: "POST", body: JSON.stringify({ current_password: currentPassword }),
        }),
    changePassword: (currentPassword: string, newPassword: string) =>
        call<{ ok: true }>("/auth/password", {
            method: "POST",
            body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
        }),
    login: (email: string, password: string) =>
        call<{ user: AccountUser }>("/auth/login", {
            method: "POST", body: JSON.stringify({ email, password }),
        }),
    logout: () => call<{ ok: true }>("/auth/logout", { method: "POST" }),
    deleteAccount: () => call<{ ok: true }>("/auth/me", { method: "DELETE" }),
    listKeys: () => call<{ keys: ApiKey[] }>("/keys"),
    apiActivity: (keyId?: string, signal?: AbortSignal) => call<ApiActivityData>(`/account/api-activity${keyId ? `?key_id=${encodeURIComponent(keyId)}` : ""}`, { signal }),
    createKey: (label: string) =>
        call<{ key: string; record: ApiKey }>("/keys", {
            method: "POST", body: JSON.stringify({ label }),
        }),
    revokeKey: (keyId: string) =>
        call<{ ok: true }>(`/keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }),
};

/**
 * The account API the UIs actually call.
 *
 * Two implementations behind one shape: Clerk when this deployment has a
 * publishable key, the local scrypt endpoints when it does not. The four skins
 * and AccountPage call this and never learn which they got — which is the only
 * reason moving identity to Clerk is not a change to four UIs and a generator.
 *
 * Resolved per call rather than once at module load, because Clerk arrives
 * asynchronously and a value captured at import time would be the wrong one.
 */
/**
 * What both implementations agree to provide.
 *
 * Written out rather than inferred from one side, because the two genuinely
 * differ and inferring from either would let the other quietly drift. The
 * methods local auth cannot honour are declared here and throw — a clear
 * refusal beats a missing property that only fails at the call site.
 */
export type AccountApi = Omit<typeof localAccountApi, "register" | "login" | "changePassword" | "deleteAccount"> & {
    deleteAccount(expectedAccountId?: string): Promise<{ ok: true; cleanupPending?: boolean }>;
    changePassword(currentPassword: string, newPassword: string, expectedAccountId?: string): Promise<{ ok: true }>;
    register(
        email: string,
        password: string,
        username?: string,
    ): Promise<
        | { status: "complete"; user: AccountUser; recovery_code: string }
        | { status: "needs_email_code"; user: null; recovery_code: string }
    >;
    login(identifier: string, password: string): Promise<SignInResult>;
    loginWithPasskey(): Promise<SignInResult>;
    verifySignIn(code: string): Promise<SignInResult>;
    resendSignInCode(): Promise<void>;
    resendSignUpCode(): Promise<void>;
    verifyEmailCode(code: string): Promise<{ user: AccountUser }>;
    signInWithSocial(provider: SocialProvider): Promise<void>;
    completeSocialRedirect(): Promise<void>;
    startPasswordReset(email: string): Promise<{ ok: true }>;
    finishPasswordReset(code: string, newPassword: string): Promise<PasswordResetResult>;
};

/** Only Clerk emails codes; local auth hands out a recovery code at signup. */
function notWithLocalAuth(what: string): never {
    throw new Error(`${what} needs email, which this deployment does not use. Use your recovery code.`);
}

const localOnlyStubs = {
    loginWithPasskey: () => notWithLocalAuth("Passkeys"),
    verifySignIn: () => notWithLocalAuth("Email verification"),
    resendSignInCode: () => notWithLocalAuth("Email verification"),
    resendSignUpCode: () => notWithLocalAuth("Email verification"),
    verifyEmailCode: () => notWithLocalAuth("Email verification"),
    startPasswordReset: () => notWithLocalAuth("Password reset by email"),
    finishPasswordReset: () => notWithLocalAuth("Password reset by email"),
    signInWithSocial: () => notWithLocalAuth("Signing in with Google, GitHub or Apple"),
    // Never reached without Clerk: nothing redirects, so nothing returns.
    completeSocialRedirect: () => Promise.resolve(),
};

export const accountApi = new Proxy({} as AccountApi, {
    get(_target, prop: string) {
        const impl: Record<string, unknown> = usesClerkAccounts()
            ? (clerkAccountApi as unknown as Record<string, unknown>)
            : ({ ...localAccountApi, ...localOnlyStubs } as unknown as Record<string, unknown>);
        return impl[prop];
    },
});


/** A short, human description of a key for a list row. */
export function describeKey(key: ApiKey): string {
    const created = key.created_at.slice(0, 10);
    if (key.revoked) return `Revoked · created ${created}`;
    if (!key.last_used_at) return `Never used · created ${created}`;
    return `Last used ${key.last_used_at.slice(0, 10)} · created ${created}`;
}

/** Default label for a new key, so the user never has to name one to start. */
export function defaultKeyLabel(existing: ApiKey[]): string {
    return `Key ${existing.length + 1}`;
}

/**
 * Saves a recovery code to a file.
 *
 * A code is only useful if it survives the tab it was shown in, and "copy"
 * puts it somewhere a clipboard manager may hand to the next app that asks.
 * A file goes where the person chooses and is still there next year.
 *
 * The contents say what the code is for, because a bare string in Downloads a
 * year from now is indistinguishable from junk.
 */
export function downloadRecoveryCode(code: string, email: string): void {
    const today = new Date().toISOString().slice(0, 10);
    const body = [
        "PrivaTools recovery code",
        "",
        `Account:  ${email}`,
        `Issued:   ${today}`,
        "",
        `    ${code}`,
        "",
        "This is the only way back into the account. PrivaTools sends no email,",
        "so there is no reset link. Keep this file, or put the code in a password",
        "manager.",
        "",
        "Using it resets the password, signs out every session, and issues a new",
        "code — this one stops working at that point.",
        "",
    ].join("\n");

    const url = URL.createObjectURL(new Blob([body], { type: "text/plain;charset=utf-8" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = `privatools-recovery-code-${today}.txt`;
    document.body.appendChild(a);
    a.click();
    a.remove();
    // Revoking immediately can race the download in some browsers.
    setTimeout(() => URL.revokeObjectURL(url), 10_000);
}