가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.
- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
`places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
스코프가 없으면 아무에게도 안 보이는 유령이다.
실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다
남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.
검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
170 lines
7.3 KiB
TypeScript
170 lines
7.3 KiB
TypeScript
import type {ComponentType, ReactNode} from 'react';
|
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
|
|
import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react';
|
|
import {cn} from '@/lib/utils';
|
|
import {userLabel, useAuthStore} from '@/stores/auth';
|
|
|
|
export type NavItem = {
|
|
to: string;
|
|
/** 활성 표시용 경로. 쿼리스트링이 붙은 `to` 로는 pathname 을 비교할 수 없다. */
|
|
match: string;
|
|
label: string;
|
|
icon: ComponentType<{className?: string}>;
|
|
};
|
|
|
|
/**
|
|
* ★ 메뉴를 이 파일에 하드코딩하지 않는다 — 앱마다 다르고, **섞이면 새어 나간다.**
|
|
* 내부 메뉴(`/places`, `/local-content`)를 여기 두면 그 경로 이름이 사장님 번들에
|
|
* 문자열로 남는다(실측: 앱을 가른 뒤에도 dist 에서 `local-content` 가 나왔다).
|
|
* 앱을 가른 이유가 그거라 메뉴도 앱이 들고 온다. 내부 메뉴는 `admin/src/app/router.tsx`.
|
|
*
|
|
* ★ 빌더는 `?new=1` 로 간다. 그냥 `/builder` 로 보내면 저장된 위저드 상태가 복원돼
|
|
* 지난번에 편집하던 가게의 에디터가 뜬다(BuilderPage 주석) — 새로 만들러 누른 사람에게는
|
|
* 남의 화면이다.
|
|
*/
|
|
const OWNER_NAV: NavItem[] = [
|
|
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
|
|
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
|
|
/*
|
|
* ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적)
|
|
* /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면
|
|
* 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다.
|
|
* 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.
|
|
* ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지
|
|
* 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다.
|
|
*/
|
|
{to: '/pricing', match: '/pricing', label: '요금', icon: Receipt},
|
|
];
|
|
|
|
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const signOut = useAuthStore((s) => s.signOut);
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
|
<aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex">
|
|
{/* ★ 로고는 언제나 홈(/)이다. 메뉴 첫 항목으로 보내면 앱마다 목적지가 달라지고,
|
|
로고를 눌러 첫 화면으로 가려던 사람이 엉뚱한 목록에 떨어진다. */}
|
|
<Link to="/" className="flex items-center gap-2 px-4 py-4">
|
|
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" />
|
|
</Link>
|
|
|
|
<nav className="flex-1 space-y-0.5 px-2">
|
|
{nav.map(({to, match, label, icon: Icon}) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
className={() =>
|
|
cn(
|
|
'flex items-center gap-2 rounded-md px-2.5 py-2 text-xs font-medium transition-colors',
|
|
location.pathname.startsWith(match)
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
: 'text-sidebar-foreground hover:bg-sidebar-accent/60',
|
|
)
|
|
}
|
|
>
|
|
<Icon className="size-4" />
|
|
<span>{label}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
|
|
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
|
|
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
|
|
<div className="border-t border-sidebar-border p-3">
|
|
{/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
|
|
{user ? (
|
|
<Link
|
|
to="/account"
|
|
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
|
|
>
|
|
{userLabel(user)}
|
|
</Link>
|
|
) : (
|
|
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
|
|
<span className="opacity-60">로그인하지 않았습니다</span>
|
|
</div>
|
|
)}
|
|
{user ? (
|
|
<button
|
|
type="button"
|
|
// 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라
|
|
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
|
|
onClick={() => {
|
|
signOut();
|
|
// ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다.
|
|
navigate('/');
|
|
}}
|
|
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>
|
|
</aside>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
<main className="flex-1 overflow-y-auto">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageContainer({
|
|
title,
|
|
description,
|
|
actions,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
actions?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl px-5 py-6">
|
|
<header className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
|
|
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
|
</div>
|
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
</header>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
icon: Icon = LayoutGrid,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon?: typeof Search;
|
|
title: string;
|
|
description?: string;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border px-6 py-14 text-center">
|
|
<Icon className="size-6 text-muted-foreground" />
|
|
<p className="text-sm font-medium">{title}</p>
|
|
{description && <p className="max-w-md text-xs text-muted-foreground">{description}</p>}
|
|
{action && <div className="mt-2">{action}</div>}
|
|
</div>
|
|
);
|
|
}
|