Merge branch 'main' into 스테이멍뭉아이템만ㅇ들ㅇ어

This commit is contained in:
Mina Choi 2026-09-02 21:59:02 +09:00
commit d376677b86
8 changed files with 164 additions and 58 deletions

View File

@ -137,8 +137,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.** 라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). 2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. ("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단). 내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
@ -177,7 +180,9 @@ OWNER role=2 → 403
``` ```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
@ -222,8 +227,11 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것 ### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
그때 `solution/frontend` 의 인증 정책을 다시 본다. `/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 - **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로

View File

@ -106,6 +106,50 @@
백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 — 백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 —
바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다. 바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다.
---
## 2026-09-02 — 회원가입과 구글 로그인
**한 일**
- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
**왜 가입부터 만들었나**
계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**로그인 관문은 에디터 진입 그대로다**
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
관문은 `EditorSignInGate`(969fb67) 한 자리다.
**밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이
된다 → [DECISIONS.md 1-5](DECISIONS.md)
- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이
None 을 만나 500 이 난다.
- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
버튼 렌더까지는 확인했다(빌려온 client_id 로).
**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜** **왜**

View File

@ -358,8 +358,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
-- 같은 구글 계정으로 두 번 가입되지 않게. provider 를 키에 넣어 수단이 늘어도 이 인덱스가 그대로 쓰인다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
@ -441,3 +439,7 @@ ALTER TABLE company.users
ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL;
ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64);
ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL;
-- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로
-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid)
WHERE deleted = FALSE AND provider_uid IS NOT NULL;

View File

@ -3,9 +3,12 @@ import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; import {NotFoundPage} from '@/pages/NotFoundPage';
import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // ★ 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),

View File

@ -1,8 +1,8 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation} from 'react-router'; import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
export type NavItem = { export type NavItem = {
to: string; to: string;
@ -30,6 +30,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
@ -58,19 +59,43 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
))} ))}
</nav> </nav>
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground"> <div className="mb-2 truncate text-[11px] text-sidebar-foreground">
{user?.name ?? user?.id} {user ? (
{user?.companyName ? ` · ${user.companyName}` : ''} <>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</>
) : (
<span className="opacity-60">로그인하지 않았습니다</span>
)}
</div> </div>
<button {user ? (
type="button" <button
onClick={signOut} type="button"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" // 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라
> // 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
<LogOut className="size-3.5" /> onClick={() => {
<span>로그아웃</span> signOut();
</button> navigate('/login');
}}
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogOut className="size-3.5" />
<span>로그아웃</span>
</button>
) : (
<Link
to="/login"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogIn className="size-3.5" />
<span>로그인</span>
</Link>
)}
</div> </div>
</aside> </aside>

View File

@ -6,11 +6,11 @@
*/ */
import {useState, type FormEvent, type ReactNode} from 'react'; import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react'; import {LogIn} from 'lucide-react';
import {login, me, UserRole} from '@/api'; import {login} from '@/api';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {establishSession} from '@/lib/session';
interface SignInFormProps { interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
@ -22,7 +22,6 @@ interface SignInFormProps {
} }
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const signIn = useAuthStore((s) => s.signIn);
const [id, setId] = useState(''); const [id, setId] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -36,20 +35,12 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return; return;
} }
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. // 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
if (!res.access_token || !res.refresh_token) { if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return; return;
} }
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
// 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다.
signIn(tokens, {userId: '', id, role: UserRole.USER});
const meRes = await me();
// 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다.
if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes));
onSignedIn?.(); onSignedIn?.();
} catch (error) { } catch (error) {
notifyApiError(error, '로그인에 실패했습니다.'); notifyApiError(error, '로그인에 실패했습니다.');

View File

@ -1,5 +1,5 @@
import {useEffect, useRef} from 'react'; import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api'; import {getAccessToken} from '@/api';
@ -15,7 +15,7 @@ import {
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin'; import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
@ -86,9 +86,11 @@ export function BuilderPage() {
const step = useBuilderStore((s) => s.step); const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep); const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user); const isSignedIn = Boolean(user) || Boolean(getAccessToken());
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId); const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -158,26 +160,49 @@ export function BuilderPage() {
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — {/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */} ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? ( <div className="flex shrink-0 items-center gap-2">
<a {publishedUrl ? (
href={publishedUrl} <a
target="_blank" href={publishedUrl}
rel="noopener noreferrer" target="_blank"
className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90" rel="noopener noreferrer"
> className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</a> <span>발행본 사이트 열기</span>
) : ( </a>
<span ) : (
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다." <span
aria-disabled="true" title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50" aria-disabled="true"
> className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</span> <span>발행본 사이트 열기</span>
)} </span>
)}
{user && (
<>
<span className="h-3.5 w-px bg-white/20" />
<span
className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)}
>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span>
<button
type="button"
onClick={signOut}
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<LogOut className="size-3" />
<span>로그아웃</span>
</button>
</>
)}
</div>
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">

View File

@ -31,6 +31,14 @@ export function toAuthUser(res: ResMe): AuthUser {
}; };
} }
/**
* 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_<sub>` 라 그대로 보이면 안 된다 —
* 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다.
*/
export function userLabel(user: AuthUser): string {
return user.name || user.email || user.id;
}
interface AuthState { interface AuthState {
user: AuthUser | null; user: AuthUser | null;
/** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */ /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */