Compare commits

...

3 Commits

Author SHA1 Message Date
h z
117a1385ab Merge pull request 'feat/apikey-alias-authorship' (#2) from feat/apikey-alias-authorship into master
Reviewed-on: #2
2026-05-16 22:06:42 +00:00
3ec528701e feat: apikey alias field + markdown/patch authorship display
- ApiKeyCreationModal: required Alias input (reuse alias = renew, with
  banner); align roles to backend allowlist (guest -> user, default
  user); fix copy bug (generatedKey.key).
- MarkdownContent + PatchCards: show author / created / last modified
  (+ by whom); formatDateTime helper (null -> "—").

Branched off fix/buildconfig-cachebust (carries the contenthash +
BuildConfig fix already deployed to prod).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:51:47 +01:00
ba08bba7de fix: valid config.json + content-hashed bundle (cache-bust)
- BuildConfig.sh: ${DEBUG:false} -> ${DEBUG:-false} and normalize to
  true/false. The old syntax produced empty -> invalid config.json
  ("DEBUG": }) when DEBUG was unset, breaking the whole frontend.
- webpack: output [name].[contenthash].js so index.html references a
  unique bundle URL each build; eliminates stale CDN/browser bundle
  after deploys (no manual cache purge needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:16 +01:00
6 changed files with 90 additions and 9 deletions

View File

@@ -5,7 +5,12 @@ FRONTEND_HOST="${FRONTEND_HOST:-http://localhost:80}"
KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}" KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}"
KC_HOST="${KC_HOST:-https://login.hangman-lab.top}" KC_HOST="${KC_HOST:-https://login.hangman-lab.top}"
KC_REALM="${KC_REALM:-Hangman-Lab}" KC_REALM="${KC_REALM:-Hangman-Lab}"
DEBUG="${DEBUG:false}" # Note: ${DEBUG:-false} (correct default syntax). The old ${DEBUG:false}
# produced an empty value when DEBUG was unset -> invalid config.json.
DEBUG="${DEBUG:-false}"
# DEBUG is emitted unquoted as a JSON boolean — guarantee it is exactly
# true/false so config.json can never be invalid JSON.
case "$DEBUG" in true|false) ;; *) DEBUG=false ;; esac
rm -f /usr/share/nginx/html/config.js rm -f /usr/share/nginx/html/config.js

View File

@@ -2,7 +2,8 @@ import React, { useEffect, useState } from "react";
import {Link, useParams} from "react-router-dom"; import {Link, useParams} from "react-router-dom";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./MarkdownContent.css"; import "./MarkdownContent.css";
import { Settings2, Pencil } from "lucide-react"; import { Settings2, Pencil, User, Clock, History } from "lucide-react";
import { formatDateTime } from "../../lib/utils";
import MarkdownView from "./MarkdownView"; import MarkdownView from "./MarkdownView";
import PatchCards from "./PatchCards"; import PatchCards from "./PatchCards";
import PermissionGuard from "../PermissionGuard"; import PermissionGuard from "../PermissionGuard";
@@ -85,6 +86,23 @@ const MarkdownContent = () => {
</div> </div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<div className="-mt-3 mb-6 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-secondary" />
{markdown.author || "—"}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
created {formatDateTime(markdown.created_at)}
</span>
<span className="inline-flex items-center gap-1.5">
<History className="h-3.5 w-3.5" />
updated {formatDateTime(markdown.updated_at)}
{markdown.last_modified_by
? ` by ${markdown.last_modified_by}`
: ""}
</span>
</div>
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/> <MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<PatchCards markdownId={id} /> <PatchCards markdownId={id} />
<MarkdownSettingModal <MarkdownSettingModal

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Plus, Pencil, Trash2, Save, Layers } from "lucide-react"; import { Plus, Pencil, Trash2, Save, Layers, User, Clock } from "lucide-react";
import { formatDateTime } from "../../lib/utils";
import MarkdownView from "./MarkdownView"; import MarkdownView from "./MarkdownView";
import PermissionGuard from "../PermissionGuard"; import PermissionGuard from "../PermissionGuard";
import { import {
@@ -137,6 +138,22 @@ const PatchCards = ({ markdownId }) => {
</div> </div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border/40 px-5 py-1.5 font-mono text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="h-3 w-3 text-secondary" />
{patch.author || "—"}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDateTime(patch.created_at)}
</span>
<span className="inline-flex items-center gap-1">
edited {formatDateTime(patch.updated_at)}
{patch.last_modified_by
? ` by ${patch.last_modified_by}`
: ""}
</span>
</div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<MarkdownView content={{ markdown: patch.content }} /> <MarkdownView content={{ markdown: patch.content }} />
</div> </div>

View File

@@ -11,14 +11,16 @@ import {
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input, Label } from '../ui/input'; import { Input, Label } from '../ui/input';
const AVAILABLE_ROLES = ['guest', 'creator', 'admin']; // Must match the backend allowlist (admin|creator|user).
const AVAILABLE_ROLES = ['user', 'creator', 'admin'];
const SELECT_CLASS = const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"; "flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50";
const ApiKeyCreationModal = ({ isOpen, onClose }) => { const ApiKeyCreationModal = ({ isOpen, onClose }) => {
const [alias, setAlias] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roles, setRoles] = useState(['guest']); const [roles, setRoles] = useState(['user']);
const [generatedKey, setGeneratedKey] = useState(null); const [generatedKey, setGeneratedKey] = useState(null);
const createApiKeyMutation = useCreateApiKey(); const createApiKeyMutation = useCreateApiKey();
@@ -44,12 +46,17 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
if (!alias.trim()) {
alert('Alias is required');
return;
}
if (!name.trim()) { if (!name.trim()) {
alert('API key name is required'); alert('API key name is required');
return; return;
} }
try { try {
const result = await createApiKeyMutation.mutateAsync({ const result = await createApiKeyMutation.mutateAsync({
alias: alias.trim(),
name: name.trim(), name: name.trim(),
roles: roles roles: roles
}); });
@@ -61,7 +68,7 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
}; };
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(generatedKey) navigator.clipboard.writeText(generatedKey.key)
.then(() => alert('API key copied to clipboard')) .then(() => alert('API key copied to clipboard'))
.catch(err => console.error('failed to copy api key:', err)); .catch(err => console.error('failed to copy api key:', err));
}; };
@@ -80,6 +87,20 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
</DialogHeader> </DialogHeader>
{!generatedKey ? ( {!generatedKey ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="api-key-alias">Alias</Label>
<Input
id="api-key-alias"
type="text"
placeholder="unique alias (reuse to renew)"
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Unique. Using an existing alias renews that
key (same key string, validity reset).
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="api-key-name">Name</Label> <Label htmlFor="api-key-name">Name</Label>
<Input <Input
@@ -130,7 +151,9 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary"> <div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary">
Please copy your API key immediately! It will only be displayed once! {generatedKey.renewed
? "Key renewed — same key string, validity reset 15 days."
: "Please copy your API key immediately! It will only be displayed once!"}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="generated-api-key">Your API Key</Label> <Label htmlFor="generated-api-key">Your API Key</Label>
@@ -154,7 +177,7 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
{!generatedKey && ( {!generatedKey && (
<Button <Button
onClick={handleGenerate} onClick={handleGenerate}
disabled={createApiKeyMutation.isLoading || !name.trim()} disabled={createApiKeyMutation.isLoading || !alias.trim() || !name.trim()}
> >
<KeyRound className="h-4 w-4" /> Generate <KeyRound className="h-4 w-4" /> Generate
</Button> </Button>

View File

@@ -5,3 +5,17 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/** Format a backend datetime string for display; '—' when missing/invalid. */
export function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -5,7 +5,11 @@ module.exports = {
entry: './src/index.js', entry: './src/index.js',
output: { output: {
path: path.resolve(__dirname, './dist'), path: path.resolve(__dirname, './dist'),
filename: 'bundle.js', // Content-hashed filename: index.html (injected by HtmlWebpackPlugin,
// and not edge-cached) points at a unique URL each build, so a new
// deploy is picked up immediately — no stale CDN/browser bundle.
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
publicPath: '/', publicPath: '/',
clean: true, clean: true,
}, },