Merge pull request 'feat/apikey-alias-authorship' (#2) from feat/apikey-alias-authorship into master
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user