로그인 폼 (LoginForm)
로그인 화면의 제목·브랜드·링크를 앱이 어디까지 정하는지, 그리고 서버가 정하는 것은 무엇인지 확인하고 호출 방법을 찾아보세요.
Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용
Interactive playground
signin-form.html 마크업 그대로예요. 제목·부제·심볼·회원가입 링크만 앱이 정합니다 — 로그인 방식 탭과 「비밀번호 찾기」는 서버 응답(availableLoginMethods · passwordResetAvailable)이 정하므로 호출 코드에 나타나지 않아요. <div x-data="loginForm({
title: "관리자",
subtitle: "hereby Medical",
showSignupLink: false,
})">
<!-- include "../../packages/components/html/signin-form.html" -->
</div>텍스트 제목 — 기본이자 정본. 8개 앱이 이 모양이에요
BI 심볼 + 브랜드 조판 제목 — showBrandLogo: true. 직원 앱 하나만 켭니다
회원가입 링크 — showSignupLink: true. 가입 화면이 있는 앱만 켭니다
계정이 없으신가요?
로그인 방식 둘 — 조직 설정이 비밀번호·휴대폰 인증을 모두 켰을 때만 상단 탭이 뜹니다
휴대폰 인증 2단계 — 인증번호를 받은 뒤. 재전송은 60초 쿨다운이에요
오류 — .ds-notice--danger. 서버가 던진 문구가 그대로 뜹니다
로그인 중 — 버튼 안 아이콘 스피너(영역 링이 아님)
signin.html 이 그립니다 — partial 은 그 안의 내용만 담당해요. 제목·부제·BI 심볼·회원가입 링크를 바꾸면 호출 코드가 함께 바뀝니다. 그 아래 「서버·사용자가 만드는 상태」(로그인 방식 탭 · 비밀번호 찾기 · 오류 · 로딩)는 호출부가 정하는 것이 아니라서 코드에 나타나지 않아요. 실제 앱 9곳이 쓰는 조합은 「모아보기」 탭에 있습니다.
규칙 (강제) · 코딩 에이전트용
LoginForm 강제 규칙. 상세는 아래 본문.
마크업
- 호스트가
x-data="loginForm({...})"로 감싸고packages/components/html/signin-form.html을 include 합니다. 로그인 폼 마크업을 손으로 짓지 마세요 — 9개 앱이 같은 파일을 인라인합니다. x-html로 폼을 주입하지 마세요. Alpine은x-html로 넣은 HTML의 디렉티브를 재스캔하지 않아 폼이 통째로 죽습니다(컴포넌트 주석에 명시된 제약).
⚠️ 트랩 ① showSignupLink 의 기본값은 true 입니다 넘기지 않으면 「계정이 없으신가요? 회원가입」 링크가 뜨고, 누르면 $store.router.navigate('/signup')으로 갑니다. 가입 라우트가 없는 앱은 반드시 showSignupLink: false를 넘기세요 — 없는 경로로 가면 어떤 x-if에도 걸리지 않아 빈 화면이 되고, 새로고침하면 라우터의 isKnownPath 검사에 걸려 404 안내로 튕깁니다.
⚠️ 트랩 ② 제목은 앱 이름이고, 브랜드는 부제입니다title에는 앱 이름(nav.admin → 「관리자」)을 넣고 브랜드는 subtitle로 내립니다. 마스터(hereby)에는 서비스명을 붙이지 않으므로 Hereby Admin 같은 조합은 존재하지 않고, hereby는 항상 소문자입니다. 예외는 앱 이름 자체가 패밀리 브랜드인 경우(직원 앱 = hereby 웍스)뿐이고, 그때만 제목과 브랜드가 겹칩니다.
⚠️ 트랩 ③ 로그인 BI 는 opt-in 이고, 락업이 아니라 심볼입니다showBrandLogo의 기본값은 false입니다 — 정본은 로그인은 텍스트, 가입만 BI 락업입니다. 켠 앱은 심볼만 올립니다(워드마크가 들어가면 부제의 서비스명과 두 번 적힙니다). 심볼을 켜면 제목이 브랜드 조판(영문 Geist SemiBold + 국문 Pretendard Bold)으로 바뀝니다.
⚠️ 트랩 ④ 오류 문구는 서버 message 가 그대로 화면에 뜹니다formatLoginError()는 Error면 e.message를 그대로 돌려주고 템플릿이 그것을 .ds-notice--danger에 넣습니다. 그래서 API에서 throw new TRPCError({ message: "Invalid credentials" })를 하면 로그인 화면에 영문이 그대로 뜹니다(2026-07-29 실제 발생). 던지는 message는 한글 · 해요체여야 합니다.
⚠️ 트랩 ⑤ 제목은 h1 태그가 아니라 div role="heading" + text-h3 입니다tokens.css의 unlayered h1 { font-size: 32px }·p { font-size: 16px; margin: 0 }가 @layer utilities의 클래스를 이깁니다 — h1에 text-h3을 붙여도 32px로, p에 mt-3을 붙여도 여백 0으로 조용히 무시됩니다. 그래서 게이트 화면(로그인·비밀번호 찾기·비밀번호 변경·가입·초대·워크스페이스 허브·접근 불가)의 제목은 <div role="heading" aria-level="1" class="text-h3 font-semibold">, 카드 아래 카피라이트는 <div class="text-b6 … mt-3">(카드와 12px)로 둡니다.
공용 파일이라는 것
signin-form.html은 9개 앱이 함께 인라인합니다. 여기에 브랜드 문자열을 박으면 다른 패밀리가 조용히 새어 나갑니다 — 심볼은$store.hereby.brandFamily로 고릅니다.- 브랜드 워드에는
translate="no"를 답니다(자동번역 금지). - 다크 모드(2026-10-19) — 공용
signin-form.html의 팔레트 유틸 24곳을 시멘틱으로 옮겼고(입력칸 · 버튼은 이미 토큰), 직원 앱 · 관리자 앱의 바깥 상자(bg-white md:bg-hr-gray-50)도bg-bg-container md:bg-bg-alternative로 바꿨다. 로그인 전 화면도 기기에 저장된 테마를 따른다(hereby Works — 전환 UI 는 Works 에만, 관리자 앱은 늘 라이트). 「패스키 로그인」 버튼은 존재하지 않는 변형ds-btn--outline이라 글자색이 고정돼 다크에서 안 보였다 —text-label-normal을 얹어 두었고 올바른 변형(ds-btn--line)으로 바꿀지는 미결. 확인: Playground 「미리보기 테마 → 다크」.
무엇을 담당하나
로그인 화면에서 폼 안쪽만 담당합니다. 제목 · 부제 · (선택) BI 심볼 · 입력 · 오류 · 제출 버튼 · 하단 링크까지입니다.
카드 셸(흰 배경 · 라운드 · 그림자 · 화면 가운데 정렬)과 하단 © 2026 hereby 저작권 줄은 **앱의 signin.html**이 그립니다. 그래서 앱마다 카드 여백이 조금씩 다를 수 있고, 그건 이 컴포넌트가 아니라 그 뷰를 봐야 합니다.
앱이 정하는 것 · 서버가 정하는 것
이 구분이 이 컴포넌트에서 가장 자주 틀리는 부분입니다.
| 화면에 보이는 것 | 누가 정하나 | 어디서 |
|---|---|---|
| 제목 · 부제 | 앱 | loginForm({ title, subtitle }) |
| BI 심볼 | 앱 | loginForm({ showBrandLogo }) — 기본 false |
| 회원가입 링크 | 앱 | loginForm({ showSignupLink }) — 기본 true |
| 로그인 방식 탭 (비밀번호 / 휴대폰 인증) | 서버 | sdk.auth.availableLoginMethods() — 조직의 loginMethods 설정 |
| 비밀번호 찾기 링크 | 서버 | sdk.auth.passwordResetAvailable() |
| 오류 문구 | 서버 | 던진 message가 그대로 |
서버가 정하는 넷은 옵션이 아니므로 호출 코드로는 켜고 끌 수 없습니다. 화면에 안 뜬다면 앱이 아니라 조직 설정과 API 응답을 보세요.
브랜드 표기
마스터에 서비스명을 붙이지 않습니다
Hereby Admin · Hereby Platform 같은 이름은 존재하지 않습니다. 제목에는 앱 이름(「관리자」·「급여 관리」)을 넣고, 브랜드는 부제로 내립니다. hereby는 어떤 경우에도 소문자이고, CSS text-transform으로 대문자가 되게 하는 것도 위반입니다.
예외는 앱 이름 자체가 패밀리 브랜드인 경우입니다. 직원 앱은 이름이 hereby 웍스라 제목과 브랜드가 한 줄에서 겹치고, 그때만 제목을 브랜드 조판으로 적습니다.
BI 심볼은 opt-in입니다. 정본은 「로그인은 텍스트, 가입만 BI 락업」이고, 직원 앱이 제품 요구로 이 규칙을 넘어 상단에 Works 심볼을 놓기로 한 것이 유일한 예외입니다. 켜더라도 올라가는 것은 심볼이지 락업이 아닙니다 — 워드마크가 들어가면 부제의 서비스명과 두 번 적힙니다.
접근성
- 아이디 입력은
autocomplete="username", 비밀번호는autocomplete="current-password", 인증번호는autocomplete="one-time-code"입니다. 브라우저·OS의 자동 채우기가 여기에 걸립니다 — 지우지 마세요. - 비밀번호 표시 토글은 아이콘 버튼이라
aria-label이 상태에 따라 바뀝니다(「비밀번호 표시」↔「비밀번호 숨기기」). - 인증번호 입력은
inputmode="numeric"+maxlength="6"이라 모바일에서 숫자 키패드가 뜹니다.
로그인 방식
조직 설정(loginMethods)이 정하고, 컴포넌트는 서버 응답을 그대로 따릅니다. 하나면 탭이 아예 안 뜹니다(availableMethods.length > 1일 때만 렌더).
| 방식 | 화면 | 흐름 |
|---|---|---|
password (기본) | 탭 없음 · ID + 비밀번호 | store.login() 또는 store.accountLogin() |
phone_otp | 상단 2칸 탭 · ID → 인증번호 | requestCode() → 6자리 입력 → verifyCode() |
휴대폰 인증은 2단계입니다. 인증번호를 보내면 ID 입력이 잠기고(:disabled="isLoading || codeSent") 안내 notice와 인증번호 칸이 나타납니다. 재전송은 60초 쿨다운이고 남은 초가 버튼에 표시됩니다. 「다시 입력」을 누르면 codeSent가 풀려 1단계로 돌아갑니다.
서버 응답을 못 받으면 비밀번호 방식으로 떨어집니다
availableLoginMethods()가 없거나 실패하면 catch가 삼키고 기본값(["password"])을 유지합니다. 그래서 API가 죽어도 로그인 화면 자체는 뜹니다 — 탭이 안 보이는 것이 곧 오류는 아닙니다.
상단 제목
| 상태 | 제목 | 조판 |
|---|---|---|
| 심볼 끔 (기본) | <div role="heading" class="text-h3 font-semibold"> + title | 한 덩어리 |
| 심볼 켬 | 심볼 → <div role="heading" class="text-h3"> → 부제 3단 | titleParts로 국문/영문을 잘라 두 폰트로 |
titleParts는 제목을 정규식(/([가-힣]+)/)으로 갈라 국문 조각에는 Pretendard Bold, 영문 조각에는 Geist SemiBold를 줍니다. 한 요소에는 서체와 weight를 하나씩밖에 못 주기 때문이고, 「hereby 웍스」처럼 두 언어가 섞인 브랜드를 한 줄로 조판하려면 이 방식이 필요합니다.
반복 템플릿 안팎에 공백·개행을 넣지 마세요
titleParts를 도는 x-for 템플릿 앞뒤에 공백이나 줄바꿈이 있으면 텍스트 노드가 복제돼 낱말 사이가 벌어집니다. signin-form.html의 해당 줄이 한 줄로 붙어 있는 것은 의도된 것입니다.
오류 · 로딩
- 오류는
.ds-notice ds-notice--danger한 종류입니다. 필드별 오류가 아니라 폼 전체에 하나이고, 방식마다 위치가 다릅니다(비밀번호 방식은 버튼 위, 휴대폰 인증은 발송 전/후 두 자리). - 로딩은 버튼 안 아이콘 스피너(
ds-ico animate-spin+ic-spinner)입니다. 영역 링(.ds-spinner)이 아닙니다 — 화면 전체가 아니라 버튼 하나가 바쁜 상황이기 때문입니다. - 제출 버튼은 입력이 비면
disabled입니다(!identifier.trim() || !password, 인증번호는length !== 6).
호출 패턴
<div x-data="loginForm({
title: $store.hereby.t('nav.admin'), // 앱 이름 — 브랜드가 아니다
subtitle: 'hereby Medical', // 브랜드는 여기로
showSignupLink: false, // ⚠️ 기본이 true — 가입 라우트 없으면 반드시 false
accountLogin: true, // 통합 로그인 — admin 전용
})">
<!-- include "../../packages/components/html/signin-form.html" -->
</div>폼을 감싸는 카드와 화면 정렬은 앱이 그립니다. 실제 signin.html의 뼈대는 이렇습니다.
<template x-if="!$store.hereby.isLoading && $store.router.path === '/signin'">
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-lg border border-gray-200 px-8 py-10">
<div x-data="loginForm({ ... })">
<!-- include "../../packages/components/html/signin-form.html" -->
</div>
</div>
<p class="text-center text-xs text-neutral-400 mt-4">© 2026 hereby</p>
</div>
</div>
</template>함께 필요한 것
- 라우터 스토어 — 하단 링크가
$store.router.navigate('/signup')·('/forgot-password')를 부릅니다. 그 경로의 뷰가 실제로 있어야 합니다. hereby스토어 — 문구(t()),sdk,login/accountLogin/loginWithCode, 그리고 심볼을 고르는brandFamily가 전부 여기서 옵니다.- 아이콘 레지스트리 —
ic-eye·ic-eye-off·ic-spinner·ic-info-circle·ic-exclamation-circle.
API
loginForm(opts: LoginFormOptions) Alpine.data factory.
LoginFormOptions (모두 optional)
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
title | string | "hereby Medical" | 상단 제목. 앱 이름을 넣습니다 |
subtitle | string | "" → auth.login_subtitle | 제목 아래 한 줄. 비우면 init()이 로케일 기본값(「로그인하세요」)을 넣습니다 |
showSignupLink | boolean | true | 하단 「회원가입」 링크. ⚠️ 기본이 켜짐 |
showBrandLogo | boolean | false | 상단에 이 앱 패밀리의 BI 심볼. 켜면 제목이 브랜드 조판으로 바뀝니다 |
accountLogin | boolean | false | 통합 로그인(Account 우선 + 레거시 폴백). store.accountLogin()을 부릅니다 — admin 전용 |
onSuccess | () => void | — | 로그인 성공 후 호출 |
컴포넌트가 노출하는 상태 (partial이 사용)
| 멤버 | 설명 |
|---|---|
title · subtitle · showSignupLink | 옵션이 들어온 값(또는 폴백) |
showForgotPassword | passwordResetAvailable() 결과. 초기값 false |
brandSymbol | showBrandLogo가 켜졌을 때만 심볼 SVG, 아니면 "" (getter) |
titleParts | 제목을 국문/영문 조각으로 자른 배열 { text, ko }[] (getter) |
availableMethods | 서버가 준 로그인 방식 목록. 초기값 ["password"] |
mode | 지금 선택된 방식 ("password" / "phone_otp") |
identifier · password · code | 입력값 |
showPassword | 비밀번호 표시 토글 |
error · isLoading | 폼 전체 오류 문구 · 제출 중 |
codeSent · isRequestingCode · cooldownSeconds | 휴대폰 인증 2단계 상태 |
메서드
| 멤버 | 설명 |
|---|---|
init() | 부제 폴백 + passwordResetAvailable() · availableLoginMethods() 조회. 둘 다 실패해도 삼키고 기본값을 유지 |
destroy() | 재전송 쿨다운 타이머 정리 |
switchMode(next) | 방식 전환 + 오류 지우기 |
submit() | 비밀번호 로그인. accountLogin 옵션에 따라 accountLogin() / login() |
requestCode() | 인증번호 발송 + 60초 쿨다운 시작 |
verifyCode() | 6자리 검증(/^\d{6}$/) 후 loginWithCode() |
오류 문구를 만드는 규칙
formatLoginError()는 셋으로 갈립니다.
- 네트워크 실패(
TypeError+fetch/Load failed/network, 또는"Failed to fetch") →auth.api_unreachable - 그 외
Error→e.message를 그대로 (서버 문구가 화면에 뜨는 지점) Error가 아닌 것 →auth.login_failed
사용처
9개 앱이 같은 partial을 인라인합니다.
| 앱 | title | showSignupLink | 그 밖 |
|---|---|---|---|
| admin | nav.admin | true | accountLogin: true |
| employee-app | hereby 웍스 | false | showBrandLogo: true |
| hr-system | nav.payroll | false | |
| team-manager | nav.team_management | false | |
| general-affairs | nav.general_affairs | false | |
| inventory | nav.inventory | false | |
| management-dashboard | nav.management_dashboard | false | |
| drive | team_drive.title | false | |
| module-manager | nav.module_manager | 생략(=true) | ⚠️ 아래 참고 |
부제는 아홉 곳 모두 hereby Medical입니다.
module-manager 는 가입 라우트가 없는데 링크가 뜹니다
showSignupLink를 생략해 기본값 true가 걸립니다. 링크를 누르면 /signup으로 가는데 module-manager에는 그 뷰도 라우트도 없어 빈 화면이 되고, 새로고침하면 404 안내로 튕깁니다. 기본값에 맡긴 앱은 여기 하나뿐입니다.
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/LoginForm.ts |
| HTML partial | packages/components/html/signin-form.html |
| 문구 | packages/core/src/locales/ko/common.json (auth.*) |
| Alpine 등록 | loginForm (plugin.ts) |
검토 필요 사항
showSignupLink의 기본값이true인 것 — 가입 화면이 있는 앱은 9곳 중 3곳(admin · employee-app · hr-system)뿐이고, 그중 둘은 명시적으로false를 넘깁니다. 실제로 켜고 쓰는 곳은 admin 하나입니다. 기본값을false로 뒤집으면 module-manager의 빈 화면이 옵션 없이 사라지지만, 기본값 변경은 동작 변경이라 이 문서가 단독으로 정할 일이 아닙니다.입력이 아직 유틸 조합입니다— 2026-10-01 정리. 네 인증 폼과 admin 가입·초대·워크스페이스 허브, 시스템 어드민 로그인의 입력을ds-input--48 w-full하나로 바꿨습니다. 손으로 붙인rounded-lg가 모서리를 10px 로 만들어 4px 인 버튼·DS 텍스트필드와 어긋났고,h-10은 DS 기본 32px 규칙에 져서 화면마다 32/40 이 섞여 있었습니다. 오류 테두리는ds-input--error입니다.
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-09-08 | LoginForm 페이지 신규 작성 — Playground(제목 · 부제 · BI 심볼 · 회원가입 링크 × 서버 상태 4종) + 모아보기 7상태 |