[feat] solution/frontend: 에디터에 로그인 사용자 표시 — 6단계엔 신원도 나가는 길도 없었다

위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는
전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도,
나가는 방법도 화면에서 사라졌다.**

- BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가
- stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는
  google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다
  (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다)

브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시,
[로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 17:29:02 +09:00
parent 5fa83933e4
commit 4d6802b4f2
3 changed files with 58 additions and 23 deletions

View File

@ -2,7 +2,7 @@ import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation} from 'react-router';
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
import {userLabel, useAuthStore} from '@/stores/auth';
export type NavItem = {
to: string;
@ -60,7 +60,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
<div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground">
{user?.name ?? user?.id}
{user ? userLabel(user) : ''}
{user?.companyName ? ` · ${user.companyName}` : ''}
</div>
<button

View File

@ -1,5 +1,5 @@
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 {SiteStatus} from '@o2o/shared';
import {AppShell} from '@/components/layout/AppShell';
@ -13,6 +13,7 @@ import {
import {EditorLayout} from '@/features/builder';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
import {userLabel, useAuthStore} from '@/stores/auth';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
@ -80,6 +81,9 @@ export function BuilderPage() {
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -144,26 +148,49 @@ export function BuilderPage() {
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? (
<a
href={publishedUrl}
target="_blank"
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>
</a>
) : (
<span
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
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>
</span>
)}
<div className="flex shrink-0 items-center gap-2">
{publishedUrl ? (
<a
href={publishedUrl}
target="_blank"
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>
</a>
) : (
<span
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
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>
</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 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 {
user: AuthUser | null;
/** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */