diff --git a/public/css/components.css b/public/css/components.css index 33ffedb..5a273bf 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -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); } diff --git a/schema.sql b/schema.sql index e535171..bfcf46a 100644 --- a/schema.sql +++ b/schema.sql @@ -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 ( diff --git a/src/db.js b/src/db.js index 71e7f84..9b76041 100644 --- a/src/db.js +++ b/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`, diff --git a/src/helpers.js b/src/helpers.js index bf3f2ec..1003ea8 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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 ( + `${title}` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + // 프로젝트 설명(README 형식) 마크다운 렌더링. // html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다. // markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다. diff --git a/src/server.js b/src/server.js index 7a13cc5..39d627b 100644 --- a/src/server.js +++ b/src/server.js @@ -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; diff --git a/views/profile.ejs b/views/profile.ejs index 42beeac..695b217 100644 --- a/views/profile.ejs +++ b/views/profile.ejs @@ -20,6 +20,7 @@

<%= profile.name %>

<%= profile.sabun %> + <% if (profile.is_admin) { %><%- adminBadge({ size: 16 }) %><% } %>
<%= profile.project_count %>개 프로젝트