feat(api-tokens): add per-user API tokens with generation and revocation

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-08 08:10:54 +02:00
1 parent 049efdedb6
commit 469641de18
17 files changed
+603 -8

No files matched your search

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "apiTokenCreatedAt" TIMESTAMP(3),
ADD COLUMN "apiTokenHash" TEXT,
ADD COLUMN "apiTokenHint" TEXT,
ADD COLUMN "apiTokenLastUsedAt" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_apiTokenHash_key" ON "User"("apiTokenHash");
+7
View File
@@ -27,6 +27,13 @@ model User {
// SHA-256 of the emailed token, so a database leak can't be used to take over accounts.
passwordResetTokenHash String? @unique
passwordResetExpires DateTime?
// A read-only token other apps use to call the API as this user. Only its SHA-256
// is stored; the token itself is shown once, when generated. One per user: generating replaces it.
apiTokenHash String? @unique
// The token's last four characters, so the profile can show which one is in use.
apiTokenHint String?
apiTokenCreatedAt DateTime?
apiTokenLastUsedAt DateTime?
// Per-user, unlike the instance-wide Thingiverse token.
makerworldCookie String?
slicer String?
+38 -8
View File
@@ -3,6 +3,8 @@ import jwt from "jsonwebtoken";
import type { Role } from "@prisma/client";
import { AUTH_ALGO, AUTH_SECRET } from "./config";
import { getAuthTokenTtl } from "./services/settingsService";
import { findApiTokenUser, isApiToken } from "./services/apiTokenService";
import { asyncHandler } from "./utils/asyncHandler";
export type TokenPayload = { sub: string; role: Role };
@@ -27,16 +29,43 @@ export function verifyToken(token: string): TokenPayload | null {
}
}
/** `?token=` is for <img>/direct file links that can't set headers. */
export function extractToken(req: Request): string | undefined {
function headerToken(req: Request): string | undefined {
const header = req.header("authorization");
const headerToken = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
return headerToken || queryToken;
return header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
/** `?token=` is for <img>/direct file links that can't set headers. */
export function extractToken(req: Request): string | undefined {
const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
return headerToken(req) || queryToken;
}
// An API token may only read. That also keeps it from minting a session (POST /refresh) or
// replacing itself, so a leaked one can't be turned into anything more.
const API_TOKEN_METHODS = new Set(["GET", "HEAD"]);
export const requireAuth = asyncHandler(async (req, res, next) => {
const token = extractToken(req);
if (token && isApiToken(token)) {
// Header only: a token in a URL ends up in proxy logs and browser history, and it lives for
// months rather than a session.
const user = token === headerToken(req) ? await findApiTokenUser(token) : null;
if (!user) {
res.status(401).json({ detail: "Unauthorized" });
return;
}
if (!API_TOKEN_METHODS.has(req.method)) {
res.status(403).json({ detail: "API tokens are read-only" });
return;
}
req.userId = user.id;
req.userRole = user.role;
req.viaApiToken = true;
next();
return;
}
const payload = token ? verifyToken(token) : null;
if (!payload) {
res.status(401).json({ detail: "Unauthorized" });
@@ -45,11 +74,12 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
req.userId = payload.sub;
req.userRole = payload.role;
next();
}
});
/** Must run after requireAuth. */
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (req.userRole !== "ADMIN") {
// Admin settings include instance secrets (SMTP, Thingiverse), which a dashboard never needs.
if (req.userRole !== "ADMIN" || req.viaApiToken) {
res.status(403).json({ detail: "Admin access required" });
return;
}
+30
View File
@@ -13,6 +13,7 @@ import { findValidInvitation } from "../services/invitationService";
import { checkCaptcha } from "../services/captchaService";
import { createLog } from "../services/auditLog";
import { seedDefaultCategories } from "../services/categoryService";
import { generateApiToken, getApiToken, revokeApiToken } from "../services/apiTokenService";
import { toUserOut } from "../dto";
import type { Prisma, Role } from "@prisma/client";
@@ -359,6 +360,35 @@ router.patch(
}),
);
// The API token itself is only in the response that generates it; this shows which one is in use.
// Generating and revoking can't be done with the token itself: requireAuth lets it only read.
router.get(
"/profile/api-token",
requireAuth,
asyncHandler(async (req, res) => {
res.json(await getApiToken(req.userId!));
}),
);
router.post(
"/profile/api-token",
requireAuth,
asyncHandler(async (req, res) => {
res.json(await generateApiToken(req.userId!));
void createLog({ userId: req.userId!, action: "api_token_generated" });
}),
);
router.delete(
"/profile/api-token",
requireAuth,
asyncHandler(async (req, res) => {
await revokeApiToken(req.userId!);
res.json(await getApiToken(req.userId!));
void createLog({ userId: req.userId!, action: "api_token_revoked" });
}),
);
// JWTs are stateless; this only records the audit-log entry.
router.post(
"/logout",
+79
View File
@@ -0,0 +1,79 @@
import crypto from "node:crypto";
import { prisma } from "../db";
// Session JWTs start "eyJ", so the prefix alone says which kind of token a request carries. It also
// makes a leaked token recognisable to secret scanners.
export const API_TOKEN_PREFIX = "tp_";
// A dashboard polls every few seconds; recording each use would be a write per request.
const LAST_USED_RESOLUTION_MS = 60 * 1000;
export type ApiTokenOut = {
configured: boolean;
hint: string | null;
created_at: Date | null;
last_used_at: Date | null;
};
const hashApiToken = (token: string) => crypto.createHash("sha256").update(token).digest("hex");
export const isApiToken = (token: string) => token.startsWith(API_TOKEN_PREFIX);
export async function getApiToken(userId: string): Promise<ApiTokenOut> {
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { apiTokenHash: true, apiTokenHint: true, apiTokenCreatedAt: true, apiTokenLastUsedAt: true },
});
return {
configured: Boolean(user.apiTokenHash),
hint: user.apiTokenHint,
created_at: user.apiTokenCreatedAt,
last_used_at: user.apiTokenLastUsedAt,
};
}
/** Replaces any existing token, which stops working at once. The token is only ever returned here. */
export async function generateApiToken(userId: string): Promise<ApiTokenOut & { token: string }> {
const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString("base64url");
const user = await prisma.user.update({
where: { id: userId },
data: {
apiTokenHash: hashApiToken(token),
apiTokenHint: token.slice(-4),
apiTokenCreatedAt: new Date(),
apiTokenLastUsedAt: null,
},
});
return {
token,
configured: true,
hint: user.apiTokenHint,
created_at: user.apiTokenCreatedAt,
last_used_at: null,
};
}
export async function revokeApiToken(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { apiTokenHash: null, apiTokenHint: null, apiTokenCreatedAt: null, apiTokenLastUsedAt: null },
});
}
/** The token's owner, or null if no user has this token. */
export async function findApiTokenUser(token: string) {
const apiTokenHash = hashApiToken(token);
const user = await prisma.user.findUnique({
where: { apiTokenHash },
select: { id: true, role: true, apiTokenLastUsedAt: true },
});
if (!user) return null;
const now = Date.now();
if (!user.apiTokenLastUsedAt || now - user.apiTokenLastUsedAt.getTime() > LAST_USED_RESOLUTION_MS) {
// Fire-and-forget: failing to record a use must never fail the request.
prisma.user
// Matched on the hash too, so a use racing a regenerate doesn't mark the new token used.
.updateMany({ where: { id: user.id, apiTokenHash }, data: { apiTokenLastUsedAt: new Date(now) } })
.catch((err) => console.error("[apiToken] Failed to record use:", err));
}
return user;
}
+2
View File
@@ -6,6 +6,8 @@ export type LogAction =
| "user_logged_out"
| "password_reset_requested"
| "password_reset"
| "api_token_generated"
| "api_token_revoked"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
+2
View File
@@ -5,6 +5,8 @@ declare global {
interface Request {
userId?: string;
userRole?: Role;
/** Signed in with the user's API token rather than a session. */
viaApiToken?: boolean;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
const app = createApp();
let session: string;
let userId: string;
function auth(t: string) {
return { Authorization: `Bearer ${t}` };
}
beforeAll(async () => {
const email = `api-token-test-${Date.now()}@example.com`;
const registered = await request(app)
.post("/api/register")
.send({ displayName: "API Token Test", email, password: "password123" });
if (registered.status !== 200) {
throw new Error(`Failed to register during test setup: ${registered.status} ${JSON.stringify(registered.body)}`);
}
// No SMTP in tests, so registration signs in immediately.
session = registered.body.token;
userId = registered.body.user.id;
});
describe("API token", () => {
it("reports no token for a new account", async () => {
const res = await request(app).get("/api/profile/api-token").set(auth(session));
expect(res.status).toBe(200);
expect(res.body).toEqual({ configured: false, hint: null, created_at: null, last_used_at: null });
});
it("generates a token that is shown once and stored only as a hash", async () => {
const res = await request(app).post("/api/profile/api-token").set(auth(session));
expect(res.status).toBe(200);
expect(res.body.token).toMatch(/^tp_[A-Za-z0-9_-]{43}$/);
expect(res.body.hint).toBe(res.body.token.slice(-4));
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.apiTokenHash).not.toContain(res.body.token);
const info = await request(app).get("/api/profile/api-token").set(auth(session));
expect(info.body.configured).toBe(true);
expect(info.body.token).toBeUndefined();
});
it("reads as its owner and records when it was last used", async () => {
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
const res = await request(app).get("/api/dashboard/summary").set(auth(body.token));
expect(res.status).toBe(200);
// The last-used write is fire-and-forget, so give it a moment.
await new Promise((resolve) => setTimeout(resolve, 200));
const info = await request(app).get("/api/profile/api-token").set(auth(session));
expect(info.body.last_used_at).not.toBeNull();
});
it("can't write, mint a session or replace itself", async () => {
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
const refresh = await request(app).post("/api/refresh").set(auth(body.token));
expect(refresh.status).toBe(403);
const regenerate = await request(app).post("/api/profile/api-token").set(auth(body.token));
expect(regenerate.status).toBe(403);
const revoke = await request(app).delete("/api/profile/api-token").set(auth(body.token));
expect(revoke.status).toBe(403);
});
it("is only accepted in the Authorization header, not ?token=", async () => {
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
const res = await request(app).get("/api/profile/api-token").query({ token: body.token });
expect(res.status).toBe(401);
});
it("stops working once regenerated", async () => {
const first = await request(app).post("/api/profile/api-token").set(auth(session));
const second = await request(app).post("/api/profile/api-token").set(auth(session));
expect(second.body.token).not.toBe(first.body.token);
expect((await request(app).get("/api/profile/api-token").set(auth(first.body.token))).status).toBe(401);
expect((await request(app).get("/api/profile/api-token").set(auth(second.body.token))).status).toBe(200);
});
it("stops working once revoked", async () => {
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
const revoked = await request(app).delete("/api/profile/api-token").set(auth(session));
expect(revoked.status).toBe(200);
expect(revoked.body.configured).toBe(false);
expect((await request(app).get("/api/profile/api-token").set(auth(body.token))).status).toBe(401);
});
it("rejects a made-up token", async () => {
const res = await request(app).get("/api/profile/api-token").set(auth("tp_not-a-real-token"));
expect(res.status).toBe(401);
});
it("can't reach admin routes, even for an admin", async () => {
await prisma.user.update({ where: { id: userId }, data: { role: "ADMIN" } });
try {
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
const res = await request(app).get("/api/admin/users").set(auth(body.token));
expect(res.status).toBe(403);
} finally {
await prisma.user.update({ where: { id: userId }, data: { role: "MEMBER" } });
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Server } from "node:http";
import { Test } from "supertest";
// supertest's request(app) listens on port 0 on every interface, then connects to 127.0.0.1 on the
// port it got. On macOS a wildcard bind succeeds even when another program already holds that port
// on 127.0.0.1 alone (VS Code helpers and limactl do), and that program takes the connection: the
// test then gets someone else's reply -- "Parse Error: Expected HTTP/", or a body without the field
// it expects -- in whichever test happens to draw the port.
//
// Binding to 127.0.0.1 itself means the port it's given is free there. That bind is asynchronous,
// though, and supertest reads the port the moment the request is built, so the bind waits until
// the request is sent and the port is filled into its URL then.
const PLACEHOLDER = "127.0.0.1:0";
type DeferredTest = InstanceType<typeof Test> & {
url: string;
_server?: Server;
unboundApp?: Server;
};
const proto = Test.prototype as unknown as {
serverAddress(this: DeferredTest, app: Server, path: string): string;
end(this: DeferredTest, fn?: (err: unknown, res: unknown) => void): DeferredTest;
};
const { serverAddress, end } = proto;
proto.serverAddress = function (app, path) {
if (app.address()) return serverAddress.call(this, app, path);
this.unboundApp = app;
return `http://${PLACEHOLDER}${path}`;
};
proto.end = function (fn) {
const app = this.unboundApp;
if (!app) return end.call(this, fn);
this.unboundApp = undefined;
app.once("error", (err) => fn?.(err, undefined));
app.listen(0, "127.0.0.1", () => {
const { port } = app.address() as { port: number };
// supertest's own field: it closes this server once the response is in.
// eslint-disable-next-line no-underscore-dangle
this._server = app;
this.url = this.url.replace(PLACEHOLDER, `127.0.0.1:${port}`);
end.call(this, fn);
});
return this;
};
+1
View File
@@ -4,6 +4,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
setupFiles: ["tests/setup.ts"],
testTimeout: 20000,
hookTimeout: 20000,
// Files share one database and mutate the same global rows, so parallel files race.
+2
View File
@@ -18,6 +18,8 @@ export type LogAction =
| "user_logged_out"
| "password_reset_requested"
| "password_reset"
| "api_token_generated"
| "api_token_revoked"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
+29
View File
@@ -15,6 +15,16 @@ export type AuthResult = { token: string; expires_in: number; user: AuthUser };
// When SMTP is configured, the account awaits email verification instead of signing in.
export type RegisterResult = AuthResult | { email_verification_required: true; email: string };
/** Which API token is in use; the token itself is only in `GeneratedApiToken`. */
export type ApiTokenInfo = {
configured: boolean;
/** The token's last four characters. */
hint: string | null;
created_at: string | null;
last_used_at: string | null;
};
export type GeneratedApiToken = ApiTokenInfo & { token: string };
export type UpdateProfileInput = {
current_password: string;
email?: string;
@@ -99,6 +109,25 @@ export const authApi = {
return res.json();
},
getApiToken: async (): Promise<ApiTokenInfo> => {
const res = await fetch(`${apiBase()}/profile/api-token`, { headers: authHeaders() });
assertOk(res, "Failed to load API token");
return res.json();
},
/** Replaces any existing token, which stops working at once. */
generateApiToken: async (): Promise<GeneratedApiToken> => {
const res = await fetch(`${apiBase()}/profile/api-token`, { method: "POST", headers: authHeaders() });
assertOk(res, "Failed to generate API token");
return res.json();
},
revokeApiToken: async (): Promise<ApiTokenInfo> => {
const res = await fetch(`${apiBase()}/profile/api-token`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Failed to revoke API token");
return res.json();
},
refresh: async (): Promise<{ token: string; expires_in: number }> => {
const res = await fetch(`${apiBase()}/refresh`, {
method: "POST",
+20
View File
@@ -174,6 +174,24 @@
"saving": "Saving…",
"genericError": "Something went wrong. Please try again.",
"backToProfile": "Back to Profile",
"apiToken": {
"heading": "API token",
"description": "Lets other apps and services connect to your Thingport library, with read-only access.",
"created": "created {{date}}",
"lastUsed": "last used {{when}}",
"neverUsed": "not used yet",
"generate": "Generate",
"regenerate": "Regenerate",
"revoke": "Revoke",
"regenerateTitle": "Regenerate API token?",
"regenerateMessage": "The current token stops working at once. Anything using it will need the new one.",
"revokeTitle": "Revoke API token?",
"revokeMessage": "The token stops working at once, and anything using it loses access to your library.",
"copyNow": "Copy your token now. It won't be shown again.",
"copy": "Copy",
"copied": "Copied",
"failed": "Something went wrong with your API token. Please try again."
},
"language": {
"heading": "Language",
"subtitle": "Choose the language used throughout Thingport."
@@ -634,6 +652,8 @@
"user_logged_out": "Logged out",
"password_reset_requested": "Requested a password reset",
"password_reset": "Reset password",
"api_token_generated": "Generated an API token",
"api_token_revoked": "Revoked their API token",
"user_invited": "Invited a user",
"model_uploaded": "Model uploaded",
"model_imported": "Model imported",
+20
View File
@@ -174,6 +174,24 @@
"saving": "Saugoma…",
"genericError": "Kažkas nutiko ne taip. Bandykite dar kartą.",
"backToProfile": "Grįžti į profilį",
"apiToken": {
"heading": "API raktas",
"description": "Leidžia kitoms programoms ir paslaugoms prisijungti prie jūsų Thingport bibliotekos tik skaitymo teisėmis.",
"created": "sukurtas {{date}}",
"lastUsed": "paskutinį kartą naudotas {{when}}",
"neverUsed": "dar nenaudotas",
"generate": "Generuoti",
"regenerate": "Generuoti iš naujo",
"revoke": "Atšaukti",
"regenerateTitle": "Generuoti API raktą iš naujo?",
"regenerateMessage": "Dabartinis raktas iškart nustos veikti. Viskam, kas jį naudoja, reikės naujo rakto.",
"revokeTitle": "Atšaukti API raktą?",
"revokeMessage": "Raktas iškart nustos veikti, ir viskas, kas jį naudoja, praras prieigą prie jūsų bibliotekos.",
"copyNow": "Nukopijuokite raktą dabar. Jis daugiau nebus rodomas.",
"copy": "Kopijuoti",
"copied": "Nukopijuota",
"failed": "Kažkas nepavyko su jūsų API raktu. Bandykite dar kartą."
},
"language": {
"heading": "Kalba",
"subtitle": "Pasirinkite kalbą, naudojamą visame Thingport."
@@ -636,6 +654,8 @@
"user_logged_out": "Atsijungė",
"password_reset_requested": "Paprašė atkurti slaptažodį",
"password_reset": "Atkūrė slaptažodį",
"api_token_generated": "Sugeneravo API raktą",
"api_token_revoked": "Atšaukė savo API raktą",
"user_invited": "Pakvietė naudotoją",
"model_uploaded": "Modelis įkeltas",
"model_imported": "Modelis importuotas",
+2
View File
@@ -32,6 +32,8 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
user_logged_out: "default",
password_reset_requested: "default",
password_reset: "warning",
api_token_generated: "warning",
api_token_revoked: "default",
user_invited: "info",
authors_linked: "success",
descriptions_refetched: "warning",
@@ -0,0 +1,205 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { authApi, type ApiTokenInfo } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
import { useConfirm } from "../../components/ConfirmProvider";
type Props = {
onUnauthorized?: () => void;
};
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["day", 24 * 60 * 60 * 1000],
["hour", 60 * 60 * 1000],
["minute", 60 * 1000],
];
function relativeTime(iso: string, locale: string): string {
const diffMs = new Date(iso).getTime() - Date.now();
const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [unit, ms] of RELATIVE_UNITS) {
if (Math.abs(diffMs) >= ms) return format.format(Math.round(diffMs / ms), unit);
}
return format.format(0, "minute");
}
/** The read-only token other apps and services use to read this user's library. The token
* is shown once, right after it's generated; after that only its last four characters. */
export default function ApiTokenSection({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const confirm = useConfirm();
const [info, setInfo] = React.useState<ApiTokenInfo | null>(null);
const [newToken, setNewToken] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const tokenInput = React.useRef<HTMLInputElement>(null);
const handleError = React.useCallback(
(err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("profile.apiToken.failed"));
},
[onUnauthorized, t],
);
React.useEffect(() => {
let active = true;
authApi
.getApiToken()
.then((res) => active && setInfo(res))
.catch((err) => active && handleError(err));
return () => {
active = false;
};
}, [handleError]);
const generate = async () => {
if (info?.configured) {
const ok = await confirm({
title: t("profile.apiToken.regenerateTitle"),
message: t("profile.apiToken.regenerateMessage"),
confirmLabel: t("profile.apiToken.regenerate"),
destructive: true,
});
if (!ok) return;
}
setBusy(true);
setError(null);
try {
const { token, ...rest } = await authApi.generateApiToken();
setInfo(rest);
setNewToken(token);
setCopied(false);
} catch (err) {
handleError(err);
} finally {
setBusy(false);
}
};
const revoke = async () => {
const ok = await confirm({
title: t("profile.apiToken.revokeTitle"),
message: t("profile.apiToken.revokeMessage"),
confirmLabel: t("profile.apiToken.revoke"),
destructive: true,
});
if (!ok) return;
setBusy(true);
setError(null);
try {
setInfo(await authApi.revokeApiToken());
setNewToken(null);
} catch (err) {
handleError(err);
} finally {
setBusy(false);
}
};
// The clipboard API only exists on HTTPS or localhost, and self-hosted instances are often plain
// HTTP on a LAN address; there the token is selected so it can be copied by hand.
const copy = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
} catch {
tokenInput.current?.select();
}
};
const details = (() => {
if (!info?.configured) return t("profile.apiToken.description");
const parts = [`tp_••••${info.hint ?? ""}`];
if (info.created_at) {
parts.push(
t("profile.apiToken.created", {
date: new Date(info.created_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
}),
}),
);
}
parts.push(
info.last_used_at
? t("profile.apiToken.lastUsed", { when: relativeTime(info.last_used_at, i18n.language) })
: t("profile.apiToken.neverUsed"),
);
return parts.join(" · ");
})();
return (
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body1">{t("profile.apiToken.heading")}</Typography>
<Typography variant="caption" color="text.secondary">
{details}
</Typography>
</Box>
{info && (
<Stack direction="row" gap={0.5} flexShrink={0}>
{info.configured && (
<Button variant="text" color="error" disabled={busy} onClick={revoke}>
{t("profile.apiToken.revoke")}
</Button>
)}
<Button variant="text" disabled={busy} onClick={generate}>
{info.configured ? t("profile.apiToken.regenerate") : t("profile.apiToken.generate")}
</Button>
</Stack>
)}
</Stack>
{newToken && (
<Alert severity="success" onClose={() => setNewToken(null)}>
<Stack spacing={1}>
<Typography variant="body2">{t("profile.apiToken.copyNow")}</Typography>
<TextField
value={newToken}
size="small"
fullWidth
inputRef={tokenInput}
onFocus={(e) => e.target.select()}
slotProps={{
htmlInput: { readOnly: true, spellCheck: false, sx: { fontFamily: "monospace" } },
input: {
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? t("profile.apiToken.copied") : t("profile.apiToken.copy")}>
<IconButton size="small" edge="end" onClick={copy} aria-label={t("profile.apiToken.copy")}>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
</InputAdornment>
),
},
}}
/>
</Stack>
</Alert>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
);
}
+3
View File
@@ -8,6 +8,7 @@ import Button from "@mui/material/Button";
import type { AuthUser } from "../../api/auth";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import ActivitySection from "./activity/ActivitySection";
import ApiTokenSection from "./ApiTokenSection";
type Props = {
user: AuthUser | null;
@@ -66,6 +67,8 @@ export default function ProfilePage({ user, onUnauthorized }: Props) {
{t("profile.changePasswordLink")}
</Button>
</Stack>
<ApiTokenSection onUnauthorized={onUnauthorized} />
</Stack>
</Stack>
<ActivitySection onUnauthorized={onUnauthorized} />