feat(profile): 관리자 인증 뱃지 표시 (사번 옆 골드 씰)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:15:04 +09:00
parent 75685e41da
commit d95f968800
6 changed files with 56 additions and 8 deletions

View File

@@ -158,6 +158,7 @@
.profile-avatar-edit:hover { filter: brightness(1.08); }
.profile-info { min-width: 0; }
.profile-idline { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.admin-badge-wrap { display: inline-flex; align-self: center; margin-left: -2px; cursor: default; }
.profile-name { margin: 0; font-size: 24px; font-weight: 800; color: var(--fg); letter-spacing: -.01em; }
.profile-sabun { font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
.profile-metaline { margin-top: 6px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }

View File

@@ -15,6 +15,8 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_mime text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
-- 한줄 소개(프로필에 표시). 본인만 편집.
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text DEFAULT '';
-- 관리자 여부. Keycloak 그룹 기준으로 로그인 시마다 갱신(소스 오브 트루스는 Keycloak).
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
CREATE TABLE IF NOT EXISTS projects (

View File

@@ -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`,

View File

@@ -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: 링크도 차단한다.

View File

@@ -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;

View File

@@ -20,6 +20,7 @@
<div class="profile-idline">
<h1 class="profile-name"><%= profile.name %></h1>
<span class="profile-sabun"><%= profile.sabun %></span>
<% if (profile.is_admin) { %><span class="admin-badge-wrap" title="관리자 인증"><%- adminBadge({ size: 16 }) %></span><% } %>
</div>
<div class="profile-metaline">
<span><%= profile.project_count %>개 프로젝트</span>