feat(profile): 관리자 인증 뱃지 표시 (사번 옆 골드 씰)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
src/db.js
14
src/db.js
@@ -6,14 +6,14 @@ export const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
||||
|
||||
export const q = (text, params) => pool.query(text, params);
|
||||
|
||||
// 로그인 시 사용자 upsert
|
||||
export async function upsertUser({ sabun, name, email }) {
|
||||
// 로그인 시 사용자 upsert. is_admin 은 Keycloak 그룹 판별값을 매 로그인마다 갱신.
|
||||
export async function upsertUser({ sabun, name, email, isAdmin }) {
|
||||
await q(
|
||||
`INSERT INTO users (sabun, name, email, avatar_seed, last_login)
|
||||
VALUES ($1,$2,$3,$1, now())
|
||||
`INSERT INTO users (sabun, name, email, avatar_seed, is_admin, last_login)
|
||||
VALUES ($1,$2,$3,$1,$4, now())
|
||||
ON CONFLICT (sabun) DO UPDATE
|
||||
SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`,
|
||||
[sabun, name, email || null]
|
||||
SET name=EXCLUDED.name, email=EXCLUDED.email, is_admin=EXCLUDED.is_admin, last_login=now()`,
|
||||
[sabun, name, email || null, !!isAdmin]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function getAvatar(sabun) {
|
||||
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
||||
export async function getUserProfile(sabun) {
|
||||
const { rows } = await q(
|
||||
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio,
|
||||
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio, u.is_admin,
|
||||
(u.avatar IS NOT NULL) AS has_avatar,
|
||||
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
||||
FROM users u WHERE u.sabun=$1`,
|
||||
|
||||
@@ -42,6 +42,49 @@ export function icon(name, opts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자 인증 뱃지 — 골드 스캘럽 씰 + 흰 체크(다색이라 icon() 대신 전용 SVG).
|
||||
// 관리자 판별은 Keycloak 그룹 기준(users.is_admin 에 로그인 시 저장). 프로필 사번 옆에 표시.
|
||||
function scallopPath(cx, cy, n, rValley, rOuter) {
|
||||
// 계곡점(rValley)들을 바깥으로 볼록한 원호로 이어 씰 톱니를 만든다.
|
||||
const half = Math.PI / n;
|
||||
const sag = rOuter - rValley * Math.cos(half); // 볼록 높이(sagitta)
|
||||
const c = 2 * rValley * Math.sin(half); // 현 길이
|
||||
const r = (sag * sag + (c / 2) * (c / 2)) / (2 * sag); // 원호 반지름
|
||||
let d = "";
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = (i / n) * 2 * Math.PI - Math.PI / 2;
|
||||
const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2;
|
||||
const x0 = (cx + rValley * Math.cos(a0)).toFixed(2), y0 = (cy + rValley * Math.sin(a0)).toFixed(2);
|
||||
const x1 = (cx + rValley * Math.cos(a1)).toFixed(2), y1 = (cy + rValley * Math.sin(a1)).toFixed(2);
|
||||
if (i === 0) d += `M ${x0} ${y0} `;
|
||||
d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${x1} ${y1} `;
|
||||
}
|
||||
return d + "Z";
|
||||
}
|
||||
|
||||
export function adminBadge(opts = {}) {
|
||||
const size = opts.size || 15;
|
||||
const title = opts.title || "관리자 인증";
|
||||
const scallop = scallopPath(8, 8, 11, 5.7, 7.5);
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" ` +
|
||||
`class="admin-badge" role="img" aria-label="${title}" style="display:block"><title>${title}</title>` +
|
||||
`<defs>` +
|
||||
`<linearGradient id="aidevAdminGold" x1="0" y1="0" x2="0" y2="1">` +
|
||||
`<stop offset="0" stop-color="#fbe58c"/><stop offset="0.45" stop-color="#eabf34"/><stop offset="1" stop-color="#c68f16"/>` +
|
||||
`</linearGradient>` +
|
||||
`<radialGradient id="aidevAdminGloss" cx="0.5" cy="0.32" r="0.65">` +
|
||||
`<stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/><stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>` +
|
||||
`</radialGradient>` +
|
||||
`</defs>` +
|
||||
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGold)"/>` +
|
||||
`<path d="${scallop}" fill="url(#aidevAdminGold)" stroke="#a9790f" stroke-opacity="0.55" stroke-width="0.5"/>` +
|
||||
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGloss)"/>` +
|
||||
`<path d="M4.8 8.25 L6.85 10.25 L11.15 5.55" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
// 프로젝트 설명(README 형식) 마크다운 렌더링.
|
||||
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
|
||||
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "path";
|
||||
import { config } from "./config.js";
|
||||
import { toolsForUser } from "./sites.js";
|
||||
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
|
||||
import { icon, identicon, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
|
||||
import { icon, identicon, identiconSvg, avatar, adminBadge, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
|
||||
import { fetchMainLang, fetchReadme } from "./gitea.js";
|
||||
import * as db from "./db.js";
|
||||
|
||||
@@ -36,6 +36,7 @@ app.use((req, res, next) => {
|
||||
res.locals.icon = icon;
|
||||
res.locals.identicon = identicon;
|
||||
res.locals.avatar = avatar;
|
||||
res.locals.adminBadge = adminBadge;
|
||||
res.locals.langColor = langColor;
|
||||
res.locals.renderMarkdown = renderMarkdown;
|
||||
res.locals.mdPlain = mdPlain;
|
||||
|
||||
Reference in New Issue
Block a user