mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat(api-tokens): add per-user API tokens with generation and revocation
This commit is contained in:
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");
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user