generated from ttipo/project-base
feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// [다른 곳 모두 로그아웃] 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsLoginHistory "다른 곳 모두 로그아웃 — 확인 창")
|
||||
// "다른 곳의 로그인을 모두 끊을까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. …" + 작은 글 "메일 프로그램은 끊기지 않습니다 — …"
|
||||
// "다른 곳에서 모두 로그아웃할까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. …"(웹메일 · 앱 세션을 함께 끊는다)
|
||||
// + 작은 글 "메일 프로그램은 로그아웃되지 않습니다. …"
|
||||
// [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소]. 끊는 동안 [모두 로그아웃]은 aria-disabled · aria-busy(초점을 지킨다)
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 제목 · 글이 "이 브라우저도 로그아웃된다"로 바뀐다 — 부모가 넘긴다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 최근 로그인 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인 기록")
|
||||
// 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
|
||||
// 「최근 로그인」 + "최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
|
||||
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
|
||||
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
|
||||
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
|
||||
// - 방식: "웹메일" / "ARA SYSTEM 앱"(계약 1절 "앱 로그인" — method app) / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
|
||||
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 앱은 운영체제 "Android"(방식 칸이 앱 이름이라), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
|
||||
// - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」 — 실패는 빨간 굵은 글자만(뱃지 없음)
|
||||
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."
|
||||
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
|
||||
@@ -120,7 +120,7 @@ function timeText(event: LoginEvent): string {
|
||||
{{ LOGIN_HISTORY_TEXT.columnIp }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnKind }}
|
||||
{{ LOGIN_HISTORY_TEXT.columnDevice }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnResult }}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 로그인된 브라우저 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저")
|
||||
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
|
||||
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
|
||||
// 설정 › 로그인 기록 › 로그인된 기기 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · 1절 "앱 로그인")
|
||||
// 웹메일 세션과 ARA SYSTEM 앱 세션을 함께 보인다 — 웹 · 앱은 세션의 client 로만 가른다(device 의 이름으로 짐작하지 않는다)
|
||||
// 「로그인된 기기」 + "웹메일 3곳"(앱 세션이 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳") · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
|
||||
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16 — 앱 세션은 늘 휴대폰) · 기기 "Edge · Windows" · "ARA SYSTEM 앱 · Android" / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
|
||||
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
|
||||
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
|
||||
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
|
||||
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
|
||||
// 아래 "메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 기기가 없습니다.")
|
||||
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
|
||||
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
|
||||
// 끊은 뒤에는 목록을 다시 받는다(스토어 — 이 브라우저도 끊겼으면 세션 만료 창으로 이어진다)
|
||||
// - 세션이 바뀌면 확인 창을 닫는다
|
||||
@@ -18,7 +19,13 @@ import { useToast } from '@/composables/useToast'
|
||||
import { END_OTHER_SESSIONS_TEXT, LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { AuthSession } from '@/shared/types/account-security'
|
||||
import { deviceLabel, endOtherSessionsMessage, isMobileDevice, otherSessions } from '@/shared/utils/account-security'
|
||||
import {
|
||||
endOtherSessionsMessage,
|
||||
isMobileSession,
|
||||
otherSessions,
|
||||
sessionCountText,
|
||||
sessionDeviceLabel,
|
||||
} from '@/shared/utils/account-security'
|
||||
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useLoginHistoryStore } from '@/stores/login-history.store'
|
||||
@@ -46,8 +53,9 @@ const others = computed(() => otherSessions(props.sessions))
|
||||
/** 서버가 이 브라우저를 알아봤다(지금 이 브라우저 줄이 있다) — 없으면 [다른 곳 모두 로그아웃]이 이 브라우저까지 끊는다 */
|
||||
const hasCurrent = computed(() => props.sessions.some((session) => session.isCurrent))
|
||||
const confirmTitle = computed(() => (hasCurrent.value ? END_OTHER_SESSIONS_TEXT.title : END_OTHER_SESSIONS_TEXT.titleAll))
|
||||
const countText = computed(() => LOGIN_HISTORY_TEXT.sessionsCount(formatCount(props.sessions.length)))
|
||||
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 브라우저가 없습니다." 를 앞에 */
|
||||
/** "웹메일 3곳" · "웹메일 2곳 · ARA SYSTEM 앱 1곳" — 세션의 client 로 센다 */
|
||||
const countText = computed(() => sessionCountText(props.sessions))
|
||||
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 기기가 없습니다." 를 앞에 */
|
||||
const noteBefore = computed(() =>
|
||||
others.value.length === 0
|
||||
? `${LOGIN_HISTORY_TEXT.noOthers}${LOGIN_HISTORY_TEXT.sessionsNoteBefore}`
|
||||
@@ -77,7 +85,7 @@ async function endSession(session: AuthSession) {
|
||||
endingId.value = session.id
|
||||
try {
|
||||
await loginHistoryStore.endSession(session.id)
|
||||
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(deviceLabel(session.device)))
|
||||
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(sessionDeviceLabel(session)))
|
||||
await focusAfterRemoval(index)
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 줄은 그대로
|
||||
@@ -188,15 +196,15 @@ watch(
|
||||
>
|
||||
<td class="sessions__icon">
|
||||
<AppIcon
|
||||
:name="isMobileDevice(session.device) ? 'smartphone' : 'monitor'"
|
||||
:label="isMobileDevice(session.device) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
|
||||
:name="isMobileSession(session) ? 'smartphone' : 'monitor'"
|
||||
:label="isMobileSession(session) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="sessions__device"
|
||||
data-session-device
|
||||
>
|
||||
{{ deviceLabel(session.device) }}
|
||||
{{ sessionDeviceLabel(session) }}
|
||||
</td>
|
||||
<td class="sessions__ip">
|
||||
<template v-if="session.ip">
|
||||
@@ -218,7 +226,7 @@ watch(
|
||||
v-if="!session.isCurrent"
|
||||
type="button"
|
||||
class="sessions__end"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(deviceLabel(session.device))"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(sessionDeviceLabel(session))"
|
||||
:aria-disabled="endingId || endingOthers ? 'true' : undefined"
|
||||
:aria-busy="endingId === session.id ? 'true' : undefined"
|
||||
data-session-end
|
||||
@@ -321,7 +329,7 @@ watch(
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸: 아이콘 16 · 브라우저(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
|
||||
/* 칸: 아이콘 16 · 기기(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
|
||||
.sessions__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
|
||||
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
|
||||
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다."(서버가 웹메일 · ARA SYSTEM 앱 세션을 끊는다) — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// - 2단계 인증을 켠 직원(12단계 — 계약 5절 "비밀번호 바꾸기")은 맨 아래 「인증 앱 코드」 칸(코드가 30초마다 바뀌어 다른 칸을 다 쓴 뒤 넣는다).
|
||||
// 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007). 코드 틀림(AUTH_006)은 그 칸 아래 · 칸을 비운다.
|
||||
// 다른 탭에서 켜서 코드 없이 보냈으면(AUTH_007) 2단계 상태를 다시 받고 칸을 보인다 — 못 받아도 칸은 보인다
|
||||
|
||||
@@ -84,7 +84,7 @@ export function useAccountSecurity() {
|
||||
return api.delete<null, null>(`/app-passwords/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 로그인된 브라우저 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
|
||||
/** 로그인된 기기(웹메일 · 앱 세션 — 항목의 client) — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
|
||||
function listAuthSessions(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AuthSession>> {
|
||||
return api.get<PaginatedResult<AuthSession>, PaginatedResult<AuthSession>>('/auth/sessions', {
|
||||
params: { limit: AUTH_SESSION_LIST_LIMIT },
|
||||
@@ -97,7 +97,7 @@ export function useAccountSecurity() {
|
||||
return api.delete<null, null>(`/auth/sessions/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 이 요청의 세션만 남기고 모두 끊는다 */
|
||||
/** 이 요청의 세션만 남기고 모두 끊는다 (앱 세션도) */
|
||||
function endOtherAuthSessions(options?: AccountSecurityRequestOptions): Promise<EndedSessions> {
|
||||
return api.delete<EndedSessions, EndedSessions>('/auth/sessions', options)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ import { createTestRouter } from '@/test-utils/router'
|
||||
import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
|
||||
|
||||
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
// 로그인된 기기 · 최근 로그인에는 ARA SYSTEM 앱의 세션 · 로그인도 섞여 온다 (계약 1절 "앱 로그인" — 세션의 client · 기록의 method 가 app)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 서버가 앱 세션 · 앱 로그인 기록에 싣는 기기 */
|
||||
const APP_ANDROID = { browser: 'ARA SYSTEM 앱', os: 'Android' }
|
||||
/**
|
||||
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
|
||||
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
|
||||
@@ -32,6 +35,7 @@ const SESSIONS: AuthSession[] = [
|
||||
lastActiveAt: local(10, 31, 11, 50),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
client: 'web',
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
@@ -40,6 +44,7 @@ const SESSIONS: AuthSession[] = [
|
||||
lastActiveAt: local(10, 31, 9, 5),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Edge', os: 'Windows' },
|
||||
client: 'web',
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
@@ -48,6 +53,16 @@ const SESSIONS: AuthSession[] = [
|
||||
lastActiveAt: local(10, 30, 22, 31),
|
||||
ip: null,
|
||||
device: { browser: 'Safari', os: 'iOS' },
|
||||
client: 'web',
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
createdAt: local(10, 28, 8),
|
||||
lastActiveAt: local(10, 30, 20, 14),
|
||||
ip: '58.29.11.4',
|
||||
device: APP_ANDROID,
|
||||
client: 'app',
|
||||
isCurrent: false,
|
||||
},
|
||||
]
|
||||
@@ -76,6 +91,13 @@ const EVENTS: LoginEvent[] = [
|
||||
device: null,
|
||||
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
|
||||
}),
|
||||
loginEvent('app1', {
|
||||
occurredAt: local(10, 31, 8, 47),
|
||||
method: 'app',
|
||||
device: APP_ANDROID,
|
||||
ip: '58.29.11.4',
|
||||
isTwoFactor: true,
|
||||
}),
|
||||
loginEvent('e4', {
|
||||
occurredAt: local(10, 30, 23, 58),
|
||||
method: 'smtp',
|
||||
@@ -124,7 +146,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
if (request.url === '/auth/sessions' && request.method === 'GET') {
|
||||
return server.sessions ? server.sessions() : ok(pageOf(SESSIONS))
|
||||
}
|
||||
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 2 })
|
||||
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 3 })
|
||||
if (request.url.startsWith('/auth/sessions/')) return server.endSession ? server.endSession(request) : ok(null)
|
||||
if (request.url === '/login-events') return server.events ? server.events(request) : ok(pageOf(EVENTS))
|
||||
return ok(null)
|
||||
@@ -166,15 +188,16 @@ describe('AccountLoginHistoryPage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('로그인된 브라우저', () => {
|
||||
it('"웹메일 3곳" · 줄마다 PC · 휴대폰 아이콘 · 브라우저 · 운영체제 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
|
||||
describe('로그인된 기기', () => {
|
||||
it('"웹메일 3곳 · ARA SYSTEM 앱 1곳" · 줄마다 PC · 휴대폰 아이콘 · 기기 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const nowDate = new Date(NOW)
|
||||
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
|
||||
expect(wrapper.get('#login-sessions-title').text()).toBe('로그인된 기기')
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳 · ARA SYSTEM 앱 1곳')
|
||||
expect(wrapper.findAll('thead').at(0)?.findAll('th').map((cell) => cell.text())).toEqual([
|
||||
'종류',
|
||||
'기기',
|
||||
'브라우저 · 운영체제',
|
||||
'IP',
|
||||
'마지막 활동',
|
||||
'로그아웃',
|
||||
@@ -183,20 +206,64 @@ describe('AccountLoginHistoryPage', () => {
|
||||
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
|
||||
['Edge · Windows', '211.186.95.252', formatRelativeDayTime(SESSIONS[1]?.lastActiveAt ?? '', nowDate)],
|
||||
['Safari · iOS', '—알 수 없음', formatRelativeDayTime(SESSIONS[2]?.lastActiveAt ?? '', nowDate)],
|
||||
['ARA SYSTEM 앱 · Android', '58.29.11.4', formatRelativeDayTime(SESSIONS[3]?.lastActiveAt ?? '', nowDate)],
|
||||
])
|
||||
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
|
||||
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
|
||||
expect(sessionRows(wrapper)[3]?.[2]).toBe('어제 오후 8:14')
|
||||
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual([
|
||||
'PC',
|
||||
'PC',
|
||||
'휴대폰',
|
||||
'휴대폰',
|
||||
])
|
||||
expect(wrapper.get('[data-session="now"]').find('[data-session-end]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[data-session-end]').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'‘Edge · Windows’ 로그아웃',
|
||||
'‘Safari · iOS’ 로그아웃',
|
||||
'‘ARA SYSTEM 앱 · Android’ 로그아웃',
|
||||
])
|
||||
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
|
||||
const note = wrapper.get('[data-sessions-note]')
|
||||
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(note.text()).toBe('메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
})
|
||||
|
||||
it('앱 줄은 client 로 정한다 — 운영체제를 몰라도 휴대폰 · 앱 이름, 앱 꼴 웹 세션은 웹메일로 센다', async () => {
|
||||
const wrapper = await mountPage({
|
||||
sessions: () =>
|
||||
ok(
|
||||
pageOf<AuthSession>([
|
||||
SESSIONS[0] as AuthSession,
|
||||
{ ...(SESSIONS[3] as AuthSession), id: 'app-unknown-os', device: { browser: 'ARA SYSTEM 앱', os: null } },
|
||||
// 웹 세션이 앱 꼴 User-Agent 를 보냈다 — 서버는 브라우저를 모른다(null)고 준다
|
||||
{ ...(SESSIONS[1] as AuthSession), id: 'web-android', device: { browser: null, os: 'Android' } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳 · ARA SYSTEM 앱 1곳')
|
||||
expect(wrapper.get('[data-session="app-unknown-os"] [data-session-device]').text()).toBe('ARA SYSTEM 앱')
|
||||
expect(wrapper.get('[data-session="app-unknown-os"] svg').attributes('aria-label')).toBe('휴대폰')
|
||||
expect(wrapper.get('[data-session="web-android"] [data-session-device]').text()).toBe('알 수 없는 브라우저 · Android')
|
||||
})
|
||||
|
||||
it('지금 이 브라우저와 앱 세션 하나면 "웹메일 1곳 · ARA SYSTEM 앱 1곳" — 앱 줄을 끊으면 "웹메일 1곳" · 다른 곳에 로그인된 기기가 없다는 안내', async () => {
|
||||
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, SESSIONS[3] as AuthSession])) })
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳 · ARA SYSTEM 앱 1곳')
|
||||
|
||||
await wrapper.get('[data-session="app"] [data-session-end]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/auth/sessions/app',
|
||||
])
|
||||
expect(toastMessages()).toEqual(['‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳')
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(
|
||||
'다른 곳에 로그인된 기기가 없습니다. 메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
it('모르는 브라우저는 "알 수 없는 브라우저"', async () => {
|
||||
const unknown: AuthSession = { ...SESSIONS[1], id: 'old', ip: null, device: null } as AuthSession
|
||||
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, unknown])) })
|
||||
@@ -215,8 +282,24 @@ describe('AccountLoginHistoryPage', () => {
|
||||
'/auth/sessions/edge',
|
||||
])
|
||||
expect(toastMessages()).toEqual(['‘Edge · Windows’에서 로그아웃했습니다.'])
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual([
|
||||
'Chrome · Windows',
|
||||
'Safari · iOS',
|
||||
'ARA SYSTEM 앱 · Android',
|
||||
])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳 · ARA SYSTEM 앱 1곳')
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
|
||||
})
|
||||
|
||||
it('맨 아래 앱 줄을 끊으면 초점은 앞 줄 — 「‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.」', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const button = wrapper.get('[data-session="app"] [data-session-end]')
|
||||
;(button.element as HTMLButtonElement).focus()
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
|
||||
})
|
||||
|
||||
@@ -226,10 +309,10 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['‘Safari · iOS’에서 로그아웃했습니다.'])
|
||||
expect(sessionRows(wrapper)).toHaveLength(2)
|
||||
expect(sessionRows(wrapper)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
|
||||
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름 — 앱 포함) → 「다른 3곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -238,7 +321,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog?.getAttribute('role')).toBe('alertdialog')
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
'이 브라우저를 뺀 3곳(Edge · Windows, Safari · iOS, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
expect(dialog?.textContent).toContain('메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
|
||||
@@ -249,12 +332,13 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
|
||||
expect(toastMessages()).toEqual(['다른 2곳에서 로그아웃했습니다.'])
|
||||
expect(toastMessages()).toEqual(['다른 3곳에서 로그아웃했습니다.'])
|
||||
expect(dialog?.hasAttribute('open')).toBe(false)
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳')
|
||||
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(
|
||||
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
'다른 곳에 로그인된 기기가 없습니다. 메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('login-sessions-title')
|
||||
})
|
||||
@@ -265,18 +349,18 @@ describe('AccountLoginHistoryPage', () => {
|
||||
// 끊은 뒤 — 이 브라우저의 세션도 끝났다: 다시 받으면 401, 갱신도 401
|
||||
restoreApiAdapter()
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 3 })
|
||||
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 4 })
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-session-end]')).toHaveLength(3)
|
||||
expect(wrapper.findAll('[data-session-end]')).toHaveLength(4)
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
|
||||
)
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 4곳(Chrome · Windows, Edge · Windows, Safari · iOS 외 1곳)에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-end-others-confirm]').trigger('click')
|
||||
@@ -288,7 +372,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
'GET /auth/sessions',
|
||||
'POST /auth/refresh',
|
||||
])
|
||||
expect(toastMessages()).toContain('이 브라우저를 포함해 3곳에서 로그아웃했습니다.')
|
||||
expect(toastMessages()).toContain('이 브라우저를 포함해 4곳에서 로그아웃했습니다.')
|
||||
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
|
||||
expect(wrapper.find('[data-session]').exists()).toBe(false)
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
@@ -317,35 +401,57 @@ describe('AccountLoginHistoryPage', () => {
|
||||
reply = ok(pageOf(SESSIONS))
|
||||
await wrapper.get('[data-sessions-error] .mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sessionRows(wrapper)).toHaveLength(3)
|
||||
expect(sessionRows(wrapper)).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('최근 로그인', () => {
|
||||
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
|
||||
it('시각 · 방식(앱 비밀번호 이름 · ARA SYSTEM 앱) · IP · 기기(앱은 운영체제, 메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const nowDate = new Date(NOW)
|
||||
const time = (index: number) => formatRelativeDayTime(EVENTS[index]?.occurredAt ?? '', nowDate)
|
||||
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램')
|
||||
expect(wrapper.findAll('thead').at(1)?.findAll('th').map((cell) => cell.text())).toEqual([
|
||||
'시각',
|
||||
'방식',
|
||||
'IP',
|
||||
'기기',
|
||||
'결과',
|
||||
])
|
||||
expect(eventRows(wrapper)).toEqual([
|
||||
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '2단계 인증으로 성공'],
|
||||
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '코드가 틀려 실패'],
|
||||
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
|
||||
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
|
||||
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
|
||||
[time(3), 'ARA SYSTEM 앱', '58.29.11.4', 'Android', '2단계 인증으로 성공'],
|
||||
[time(4), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
|
||||
[time(5), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
|
||||
])
|
||||
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
|
||||
expect(eventRows(wrapper)[3]?.[0]).toBe('오늘 오전 8:47')
|
||||
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
])
|
||||
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('운영체제를 모르는 앱 줄의 기기는 —', async () => {
|
||||
const unknownOs = loginEvent('app2', {
|
||||
method: 'app',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: null },
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
})
|
||||
const wrapper = await mountPage({ events: () => ok(pageOf([unknownOs])) })
|
||||
|
||||
expect(eventRows(wrapper)[0]?.slice(1)).toEqual(['ARA SYSTEM 앱', '211.186.95.252', '—알 수 없음', '비밀번호가 틀려 실패'])
|
||||
})
|
||||
|
||||
it('[실패만] — isSuccess=false 로 처음부터 받는다 · "최근 90일 · 실패 4번"(서버의 total) · 전환은 aria-pressed', async () => {
|
||||
const failures = EVENTS.filter((event) => !event.isSuccess)
|
||||
const wrapper = await mountPage({
|
||||
@@ -435,14 +541,14 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await flushPromises()
|
||||
expect(gates).toHaveLength(2)
|
||||
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(6)
|
||||
|
||||
// 늦게 온 「실패만」 응답은 버린다
|
||||
gates[1]?.(ok(pageOf([EVENTS[0] as LoginEvent])))
|
||||
gates[0]?.(ok(pageOf([EVENTS[1] as LoginEvent], null, 1)))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual(['e1'])
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램')
|
||||
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -469,7 +575,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
release(ok(pageOf(EVENTS)))
|
||||
await flushPromises()
|
||||
expect(again.get('[data-events-filter="all"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(again.findAll('[data-login-event]')).toHaveLength(5)
|
||||
expect(again.findAll('[data-login-event]')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('기록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
|
||||
@@ -477,12 +583,12 @@ describe('AccountLoginHistoryPage', () => {
|
||||
const wrapper = await mountPage({ events: () => reply })
|
||||
|
||||
expect(wrapper.get('[data-events-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(sessionRows(wrapper)).toHaveLength(3)
|
||||
expect(sessionRows(wrapper)).toHaveLength(4)
|
||||
|
||||
reply = ok(pageOf(EVENTS))
|
||||
await wrapper.get('[data-events-error] .mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 로그인 기록 (디자인 원본 SettingsLoginHistory · README "12단계 › 설정 › 계정 › 로그인 기록" · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
// — /settings/account/login-history · 탭 제목 "로그인 기록"
|
||||
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 브라우저(LoginSessionList) → 최근 로그인(LoginEventTable)
|
||||
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 기기(LoginSessionList — 웹메일 · ARA SYSTEM 앱 세션) → 최근 로그인(LoginEventTable)
|
||||
// - 들어올 때마다 둘 다 새로 받는다(login-history.store). 묶음마다 받는 동안은 자리만, 못 받으면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도])
|
||||
// - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(서버가 끊은 세션 id 를 막는다 — 계약 5절)
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -81,10 +81,11 @@ describe('AccountPasswordPage', () => {
|
||||
expect(wrapper.get('#password-next-hint').text()).toBe(
|
||||
'8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
|
||||
)
|
||||
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
|
||||
// 모든 직원 — 다른 곳 로그인(웹메일 · ARA SYSTEM 앱)이 끊기고 앱 비밀번호도 모두 지워진다 (12단계 · 앱 1단계)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
|
||||
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저에서 모두 로그아웃됩니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).not.toContain('웹메일에서 로그아웃')
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호" · 12단계 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원")
|
||||
// — /settings/account/password · 설정의 첫 화면
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기와 브라우저의 로그인(웹메일 · ARA SYSTEM 앱)이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
|
||||
// 2단계 인증 상태는 들어올 때마다 새로 받는다(two-factor.store) — 켠 직원이면 머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸이 생긴다.
|
||||
// 못 받아도 그대로 쓸 수 있다 — 켰는데 코드 없이 보내면 서버가 AUTH_007 로 알려 와 양식이 상태를 다시 받고 칸을 보인다
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
// (로그인 코드 단계 LoginTwoFactor · 설정 › 계정 › 2단계 인증 SettingsTwoFactor · 앱 비밀번호 SettingsAppPasswords · 로그인 기록 SettingsLoginHistory)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 서버가 거절한 까닭의 문구는 에러 코드 사전(AUTH_006 ~ AUTH_009)에서만 온다.
|
||||
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
|
||||
/**
|
||||
* 우리 모바일 앱의 화면 이름 — 로그인된 기기의 앱 세션 줄 · 개수, 로그인 기록의 방식 (계약 1절 "앱 로그인").
|
||||
* 서버가 앱 세션 · 앱 로그인 기록의 device.browser 로 보내는 글과 같다 — 짝: backend shared/utils/user-agent.ts 의 APP_BROWSER_NAME.
|
||||
* 화면은 세션의 client · 기록의 method 가 app 일 때 이 글을 쓴다(device 의 이름으로 앱을 짐작하지 않는다)
|
||||
*/
|
||||
export const MOBILE_APP_NAME = `${APP_NAME} 앱`
|
||||
|
||||
/** 인증 앱 코드의 자릿수 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙") */
|
||||
export const OTP_CODE_LENGTH = 6
|
||||
|
||||
@@ -30,7 +39,7 @@ export const APP_PASSWORD_NAME_MAX_LENGTH = 40
|
||||
*/
|
||||
export const TWO_FACTOR_KEY_GROUPS_PER_LINE = 4
|
||||
|
||||
/** 로그인된 브라우저를 한 번에 받는 수 — 서버 최대(100). 직원 한 사람의 웹메일 세션은 이보다 적다 */
|
||||
/** 로그인된 기기를 한 번에 받는 수 — 서버 최대(100). 직원 한 사람의 웹메일 · 앱 세션은 이보다 적다 */
|
||||
export const AUTH_SESSION_LIST_LIMIT = 100
|
||||
|
||||
/**
|
||||
@@ -218,24 +227,33 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
/** 화면 틀의 description(글만) — 링크를 그릴 수 없는 곳에서 */
|
||||
description:
|
||||
'최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.',
|
||||
sessionsTitle: '로그인된 브라우저',
|
||||
sessionsCount: (count: string) => `웹메일 ${count}곳`,
|
||||
/** 웹메일 세션과 ARA SYSTEM 앱 세션을 함께 보인다 (계약 1절 "앱 로그인" · 5절) */
|
||||
sessionsTitle: '로그인된 기기',
|
||||
/**
|
||||
* 개수 — 세션의 client 로 나눠 센다(sessionCountText). 앱 세션이 없으면 "웹메일 3곳",
|
||||
* 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳" — 0곳인 쪽은 쓰지 않는다
|
||||
*/
|
||||
sessionsCountWeb: (count: string) => `웹메일 ${count}곳`,
|
||||
sessionsCountApp: (count: string) => `${MOBILE_APP_NAME} ${count}곳`,
|
||||
endOthers: '다른 곳 모두 로그아웃',
|
||||
columnDevice: '브라우저 · 운영체제',
|
||||
/** 기기 칸 — "Edge · Windows" · "ARA SYSTEM 앱 · Android" (로그인 기록 표의 기기 칸과 같은 이름) */
|
||||
columnDevice: '기기',
|
||||
columnIp: 'IP',
|
||||
columnLastActive: '마지막 활동',
|
||||
columnKind: '기기',
|
||||
/** 줄 앞 아이콘의 이름 — PC · 휴대폰 */
|
||||
/** 줄 앞 아이콘 칸의 이름 — 화면 낭독기만 읽는다(아이콘 이름이 PC · 휴대폰) */
|
||||
columnKind: '종류',
|
||||
/** 줄 앞 아이콘의 이름 — PC · 휴대폰(앱 세션은 운영체제를 몰라도 휴대폰) */
|
||||
kindPc: 'PC',
|
||||
kindMobile: '휴대폰',
|
||||
columnActions: '로그아웃',
|
||||
/** 지금 이 웹 브라우저 자신 — 웹 화면이라 지금 세션은 늘 브라우저다 */
|
||||
current: '지금 이 브라우저',
|
||||
endSession: '로그아웃',
|
||||
endSessionLabel: (device: string) => `‘${device}’ 로그아웃`,
|
||||
unknownBrowser: '알 수 없는 브라우저',
|
||||
unknownValue: '알 수 없음',
|
||||
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
|
||||
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
|
||||
noOthers: '다른 곳에 로그인된 기기가 없습니다. ',
|
||||
sessionsNoteBefore: '메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 ',
|
||||
sessionsNoteLink: '앱 비밀번호',
|
||||
sessionsNoteAfter: '를 지워 주세요.',
|
||||
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
|
||||
@@ -243,7 +261,7 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
/** 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음) — 이 브라우저까지 끊었다 */
|
||||
allEnded: (count: string) => `이 브라우저를 포함해 ${count}곳에서 로그아웃했습니다.`,
|
||||
eventsTitle: '최근 로그인',
|
||||
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
|
||||
eventsHintAll: `최근 90일 · 웹메일, ${MOBILE_APP_NAME}, 메일 프로그램`,
|
||||
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
|
||||
eventsHintFailuresUnknown: '최근 90일 · 실패만',
|
||||
filterLabel: '보일 기록',
|
||||
@@ -253,6 +271,8 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
columnMethod: '방식',
|
||||
columnResult: '결과',
|
||||
methodWebmail: '웹메일',
|
||||
/** 앱 로그인(method app) — 앱 줄의 기기 칸은 운영체제만 쓴다(이 칸이 앱 이름이라) */
|
||||
methodApp: MOBILE_APP_NAME,
|
||||
methodImap: '메일 프로그램 · IMAP',
|
||||
methodSmtp: '메일 프로그램 · SMTP',
|
||||
appPassword: (name: string) => `앱 비밀번호 ‘${name}’`,
|
||||
@@ -270,19 +290,19 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
emptyAllHint: '이 기능이 생긴 날부터 기록이 쌓입니다.',
|
||||
} as const
|
||||
|
||||
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) */
|
||||
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) — 웹메일과 ARA SYSTEM 앱 세션을 함께 끊는다 */
|
||||
export const END_OTHER_SESSIONS_TEXT = {
|
||||
title: '다른 곳에서 모두 로그아웃할까요?',
|
||||
/** "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
|
||||
/** "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
|
||||
message: (count: string, names: string) =>
|
||||
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
|
||||
`이 브라우저를 뺀 ${count}곳(${names})에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
|
||||
/**
|
||||
* 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음 — 서버는 이 요청의 세션을 몰라 모두 끊는다, 계약 5절) —
|
||||
* 이 브라우저도 로그아웃된다고 알린다
|
||||
*/
|
||||
titleAll: '이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
|
||||
messageAll: (count: string, names: string) =>
|
||||
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
|
||||
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
|
||||
moreNames: (count: string) => `외 ${count}곳`,
|
||||
note: '메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
confirm: '모두 로그아웃',
|
||||
|
||||
@@ -31,7 +31,8 @@ export const PASSWORD_TEXT = {
|
||||
codeHint: '코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.',
|
||||
submit: '비밀번호 바꾸기',
|
||||
done: '비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.',
|
||||
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
|
||||
/** 서버는 이 요청의 세션만 남기고 그 직원의 웹메일 · ARA SYSTEM 앱 세션을 모두 끊는다 (계약 1절 "앱 로그인" · 4단계) */
|
||||
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저에서 모두 로그아웃됩니다.',
|
||||
/** 모든 직원 (12단계) — 비밀번호를 바꾸면 서버가 그 직원의 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") */
|
||||
noteAppPasswords: '앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
} as const
|
||||
|
||||
@@ -319,9 +319,9 @@ export const ICONS = {
|
||||
],
|
||||
/** 설정 메뉴의 로그인 기록 (되감는 시계) */
|
||||
history: [path('M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'), path('M3 3v5h5'), path('M12 7v5l4 2')],
|
||||
/** 로그인된 브라우저 — PC */
|
||||
/** 로그인된 기기 — PC */
|
||||
monitor: [rect(2, 3, 20, 14, 2), line(8, 21, 16, 21), line(12, 17, 12, 21)],
|
||||
/** 로그인된 브라우저 — 휴대폰 */
|
||||
/** 로그인된 기기 — 휴대폰 (휴대폰 브라우저 · ARA SYSTEM 앱) */
|
||||
smartphone: [rect(5, 2, 14, 20, 2), line(12, 18, 12.01, 18)],
|
||||
/** 설정 메뉴의 메일 프로그램 연결 — PC · 휴대폰 (13단계, lucide monitor-smartphone) */
|
||||
'monitor-smartphone': [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 계정 보안 API 공유 타입 — docs/mail/api-contract.md 5절 "계정 보안 — 12단계" 와 같은 구조
|
||||
// (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록). 로그인 · 비밀번호 바꾸기에 더한 칸은 shared/types/auth.ts
|
||||
// (2단계 인증 · 앱 비밀번호 · 로그인된 기기 · 로그인 기록). 로그인 · 비밀번호 바꾸기에 더한 칸은 shared/types/auth.ts
|
||||
// 앱 세션 · 앱 로그인 기록(client · method 가 app)은 계약 1절 "앱 로그인"
|
||||
|
||||
/** GET /two-factor · 켜기 · 끄기의 응답 — 엔진이 기준이다 */
|
||||
export interface TwoFactorStatus {
|
||||
@@ -60,13 +61,22 @@ export interface CreatedAppPassword extends AppPassword {
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 서버가 User-Agent 를 짧은 이름으로 바꾼 것 — 모르면 null */
|
||||
/**
|
||||
* 서버가 User-Agent 를 짧은 이름으로 바꾼 것 — 모르면 null.
|
||||
* 앱 세션 · 앱 로그인 기록은 browser 가 "ARA SYSTEM 앱"이고 os 는 Android · iOS 또는 null 이다
|
||||
*/
|
||||
export interface LoginDevice {
|
||||
browser: string | null
|
||||
os: string | null
|
||||
}
|
||||
|
||||
/** GET /auth/sessions 의 한 줄 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 */
|
||||
/**
|
||||
* 세션을 연 곳 — web(웹메일 · 브라우저) · app(ARA SYSTEM 앱). 앱 1단계 전에 연 세션은 web.
|
||||
* 앱인지는 이 칸으로만 가른다 — device 의 이름(User-Agent)으로 짐작하지 않는다
|
||||
*/
|
||||
export type SessionClient = 'web' | 'app'
|
||||
|
||||
/** GET /auth/sessions 의 한 줄 (웹메일 · 앱 세션) — 지금 세션이 맨 앞, 그다음 최근 활동 차례 */
|
||||
export interface AuthSession {
|
||||
id: string
|
||||
createdAt: string
|
||||
@@ -75,6 +85,8 @@ export interface AuthSession {
|
||||
/** 12단계 전에 연 세션은 null */
|
||||
ip: string | null
|
||||
device: LoginDevice | null
|
||||
client: SessionClient
|
||||
/** 이 요청의 세션인가 — 웹이 부르면 지금 이 브라우저 */
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
@@ -83,7 +95,8 @@ export interface EndedSessions {
|
||||
ended: number
|
||||
}
|
||||
|
||||
export type LoginMethod = 'webmail' | MailClientProtocol
|
||||
/** 로그인 방식 — 웹메일 · ARA SYSTEM 앱(계약 1절 "앱 로그인") · 메일 프로그램(IMAP · SMTP) */
|
||||
export type LoginMethod = 'webmail' | 'app' | MailClientProtocol
|
||||
|
||||
/** 실패한 까닭 — wrongPassword(웹메일에 넣은 앱 비밀번호 포함) · wrongCode(다시 쓴 코드 포함) · appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로) */
|
||||
export type LoginFailureReason = 'wrongPassword' | 'wrongCode' | 'appPasswordRequired'
|
||||
@@ -94,11 +107,11 @@ export interface LoginEvent {
|
||||
occurredAt: string
|
||||
method: LoginMethod
|
||||
isSuccess: boolean
|
||||
/** 웹메일 성공이 코드까지 거쳤는지 */
|
||||
/** 웹메일 · 앱 성공이 코드까지 거쳤는지 */
|
||||
isTwoFactor: boolean
|
||||
failureReason: LoginFailureReason | null
|
||||
ip: string | null
|
||||
/** 웹메일만 — 메일 프로그램은 null */
|
||||
/** 웹메일 · 앱만 — 메일 프로그램은 null */
|
||||
device: LoginDevice | null
|
||||
/** 메일 프로그램이 앱 비밀번호로 들어온 경우 — name 은 그때의 이름(지운 뒤에도 남는다) */
|
||||
appPassword: { id: string; name: string } | null
|
||||
|
||||
@@ -12,18 +12,25 @@ import {
|
||||
groupCharacters,
|
||||
isCompleteOtp,
|
||||
isMobileDevice,
|
||||
isMobileSession,
|
||||
loginAppPasswordLabel,
|
||||
loginDeviceLabel,
|
||||
loginMethodLabel,
|
||||
loginResultLabel,
|
||||
normalizeOtpInput,
|
||||
otherSessions,
|
||||
sessionCounts,
|
||||
sessionCountText,
|
||||
sessionDeviceLabel,
|
||||
twoFactorStateLabel,
|
||||
} from './account-security'
|
||||
|
||||
// 계정 보안 화면의 순수 함수 (계약 5절 · 디자인 README "12단계 — 계정 보안")
|
||||
// 앱 세션 · 앱 로그인 기록(계약 1절 "앱 로그인")은 세션의 client · 기록의 method 로만 가른다
|
||||
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
/** 서버가 앱 세션 · 앱 로그인 기록에 싣는 기기 (backend user-agent.ts 의 describeAppUserAgent) */
|
||||
const APP_ANDROID = { browser: 'ARA SYSTEM 앱', os: 'Android' }
|
||||
|
||||
function event(changes: Partial<LoginEvent>): LoginEvent {
|
||||
return {
|
||||
@@ -47,11 +54,16 @@ function session(changes: Partial<AuthSession>): AuthSession {
|
||||
lastActiveAt: '2026-10-31T00:05:00Z',
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Edge', os: 'Windows' },
|
||||
client: 'web',
|
||||
isCurrent: false,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
function appSession(changes: Partial<AuthSession>): AuthSession {
|
||||
return session({ device: APP_ANDROID, client: 'app', ...changes })
|
||||
}
|
||||
|
||||
describe('인증 앱 코드 칸', () => {
|
||||
it('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈 · 전각 숫자', () => {
|
||||
expect(normalizeOtpInput('123 456')).toBe('123456')
|
||||
@@ -108,6 +120,54 @@ describe('기기 이름', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('로그인된 기기 — 웹메일 · ARA SYSTEM 앱 세션', () => {
|
||||
it('줄 이름 — 웹 세션은 "브라우저 · 운영체제", 앱 세션은 "ARA SYSTEM 앱 · Android"(운영체제를 모르면 앱 이름만)', () => {
|
||||
expect(sessionDeviceLabel(session({}))).toBe('Edge · Windows')
|
||||
expect(sessionDeviceLabel(session({ device: null }))).toBe('알 수 없는 브라우저')
|
||||
expect(sessionDeviceLabel(appSession({}))).toBe('ARA SYSTEM 앱 · Android')
|
||||
expect(sessionDeviceLabel(appSession({ device: { browser: 'ARA SYSTEM 앱', os: 'iOS' } }))).toBe('ARA SYSTEM 앱 · iOS')
|
||||
expect(sessionDeviceLabel(appSession({ device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBe('ARA SYSTEM 앱')
|
||||
// 앱 이름은 client 로 붙인다 — 서버의 기기 이름이 비어 있어도 "알 수 없는 브라우저"가 아니다
|
||||
expect(sessionDeviceLabel(appSession({ device: null }))).toBe('ARA SYSTEM 앱')
|
||||
expect(sessionDeviceLabel(appSession({ device: { browser: null, os: 'Android' } }))).toBe('ARA SYSTEM 앱 · Android')
|
||||
})
|
||||
|
||||
it('웹 세션이 앱 꼴 User-Agent 를 보냈어도 앱으로 보지 않는다 — 서버는 브라우저 null 로 준다', () => {
|
||||
const spoofed = session({ device: { browser: null, os: 'Android' } })
|
||||
expect(sessionDeviceLabel(spoofed)).toBe('알 수 없는 브라우저 · Android')
|
||||
expect(sessionCounts([spoofed])).toEqual({ web: 1, app: 0 })
|
||||
})
|
||||
|
||||
it('아이콘 — 앱 세션은 운영체제를 몰라도 휴대폰, 웹 세션은 운영체제로(모르면 PC)', () => {
|
||||
expect(isMobileSession(appSession({}))).toBe(true)
|
||||
expect(isMobileSession(appSession({ device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBe(true)
|
||||
expect(isMobileSession(appSession({ device: null }))).toBe(true)
|
||||
expect(isMobileSession(session({ device: { browser: 'Safari', os: 'iOS' } }))).toBe(true)
|
||||
expect(isMobileSession(session({}))).toBe(false)
|
||||
expect(isMobileSession(session({ device: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('개수는 client 로 센다 — 기기 이름이 앱 이름이어도 web 세션은 웹메일 쪽', () => {
|
||||
const sessions = [
|
||||
session({ id: 'now', isCurrent: true }),
|
||||
appSession({ id: 'phone' }),
|
||||
session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } }),
|
||||
// 이름만 보고 짐작하지 않는다
|
||||
session({ id: 'c', device: { browser: 'ARA SYSTEM 앱', os: 'Android' } }),
|
||||
appSession({ id: 'tablet', device: null }),
|
||||
]
|
||||
expect(sessionCounts(sessions)).toEqual({ web: 3, app: 2 })
|
||||
expect(sessionCountText(sessions)).toBe('웹메일 3곳 · ARA SYSTEM 앱 2곳')
|
||||
})
|
||||
|
||||
it('개수 글 — 앱 세션이 없으면 "웹메일 3곳", 0곳인 쪽은 쓰지 않는다', () => {
|
||||
expect(sessionCountText([session({ id: 'a' }), session({ id: 'b' }), session({ id: 'c' })])).toBe('웹메일 3곳')
|
||||
expect(sessionCountText([session({ id: 'a' }), appSession({ id: 'b' })])).toBe('웹메일 1곳 · ARA SYSTEM 앱 1곳')
|
||||
expect(sessionCountText([appSession({ id: 'b' })])).toBe('ARA SYSTEM 앱 1곳')
|
||||
expect(sessionCountText([])).toBe('웹메일 0곳')
|
||||
})
|
||||
})
|
||||
|
||||
describe('2단계 인증 상태 글', () => {
|
||||
it('꺼짐 · 켜짐 — 웹메일에서 켠 때가 있으면 "2026년 10월 31일부터"', () => {
|
||||
expect(twoFactorStateLabel({ isEnabled: false, enabledAt: null })).toBe('꺼짐')
|
||||
@@ -146,8 +206,9 @@ describe('앱 비밀번호 이름', () => {
|
||||
})
|
||||
|
||||
describe('로그인 기록', () => {
|
||||
it('방식 — 웹메일 · 메일 프로그램 · IMAP · 메일 프로그램 · SMTP', () => {
|
||||
it('방식 — 웹메일 · ARA SYSTEM 앱 · 메일 프로그램 · IMAP · 메일 프로그램 · SMTP', () => {
|
||||
expect(loginMethodLabel('webmail')).toBe('웹메일')
|
||||
expect(loginMethodLabel('app')).toBe('ARA SYSTEM 앱')
|
||||
expect(loginMethodLabel('imap')).toBe('메일 프로그램 · IMAP')
|
||||
expect(loginMethodLabel('smtp')).toBe('메일 프로그램 · SMTP')
|
||||
})
|
||||
@@ -165,6 +226,23 @@ describe('로그인 기록', () => {
|
||||
expect(loginDeviceLabel(event({ method: 'smtp', device: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('앱 줄의 기기 — 운영체제만(방식 칸이 앱 이름이다), 운영체제를 모르면 null', () => {
|
||||
expect(loginDeviceLabel(event({ method: 'app', device: APP_ANDROID }))).toBe('Android')
|
||||
expect(loginDeviceLabel(event({ method: 'app', device: { browser: 'ARA SYSTEM 앱', os: 'iOS' } }))).toBe('iOS')
|
||||
expect(loginDeviceLabel(event({ method: 'app', device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBeNull()
|
||||
expect(loginDeviceLabel(event({ method: 'app', device: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('앱 로그인의 결과 글도 웹메일과 같다 — 코드까지 거치면 「2단계 인증으로 성공」', () => {
|
||||
expect(loginResultLabel(event({ method: 'app', device: APP_ANDROID, isTwoFactor: true }))).toEqual({
|
||||
label: '2단계 인증으로 성공',
|
||||
isFailure: false,
|
||||
})
|
||||
expect(
|
||||
loginResultLabel(event({ method: 'app', device: APP_ANDROID, isSuccess: false, failureReason: 'wrongCode' })),
|
||||
).toEqual({ label: '코드가 틀려 실패', isFailure: true })
|
||||
})
|
||||
|
||||
it('결과 글 다섯 가지 — 실패는 isFailure', () => {
|
||||
expect(loginResultLabel(event({}))).toEqual({ label: '성공', isFailure: false })
|
||||
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '2단계 인증으로 성공', isFailure: false })
|
||||
@@ -186,34 +264,46 @@ describe('로그인 기록', () => {
|
||||
})
|
||||
|
||||
describe('다른 곳 모두 로그아웃', () => {
|
||||
it('지금 이 브라우저를 뺀 세션 · 확인 창 글은 이름을 세 곳까지, 넘으면 "외 n곳"', () => {
|
||||
it('지금 이 브라우저를 뺀 세션(앱 세션 포함) · 확인 창 글은 이름을 세 곳까지, 넘으면 "외 n곳"', () => {
|
||||
const sessions = [
|
||||
session({ id: 'now', isCurrent: true, device: { browser: 'Chrome', os: 'Windows' } }),
|
||||
session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }),
|
||||
session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } }),
|
||||
appSession({ id: 'phone' }),
|
||||
]
|
||||
const others = otherSessions(sessions)
|
||||
expect(others.map((item) => item.id)).toEqual(['a', 'b'])
|
||||
expect(others.map((item) => item.id)).toEqual(['a', 'phone'])
|
||||
expect(endOtherSessionsMessage(others)).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
|
||||
const many = [
|
||||
...others,
|
||||
session({ id: 'c', device: { browser: 'Firefox', os: 'Linux' } }),
|
||||
session({ id: 'd', device: null }),
|
||||
session({ id: 'e', device: null }),
|
||||
appSession({ id: 'e', device: null }),
|
||||
]
|
||||
expect(endOtherSessionsMessage(many)).toBe(
|
||||
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
'이 브라우저를 뺀 5곳(Edge · Windows, ARA SYSTEM 앱 · Android, Firefox · Linux 외 2곳)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('웹 세션만 있어도 같은 글이다 — 웹메일만 가리키지 않는다', () => {
|
||||
const others = [session({ id: 'a' }), session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } })]
|
||||
|
||||
expect(endOtherSessionsMessage(others)).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 이 브라우저도 로그아웃된다는 글', () => {
|
||||
const unknown = [session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }), session({ id: 'b', device: null })]
|
||||
const unknown = [
|
||||
session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }),
|
||||
session({ id: 'b', device: null }),
|
||||
appSession({ id: 'phone', device: { browser: 'ARA SYSTEM 앱', os: null } }),
|
||||
]
|
||||
|
||||
expect(endOtherSessionsMessage(unknown, true)).toBe(
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Edge · Windows, 알 수 없는 브라우저, ARA SYSTEM 앱)에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 계정 보안 화면의 순수 함수 — 계약 5절 "계정 보안 — 12단계" · 디자인 README "12단계 — 계정 보안"
|
||||
// 인증 앱 코드 칸 다듬기 · 4자씩 끊어 보이기 · 기기 이름 · 앱 비밀번호 이름 검사 · 로그인 기록의 방식 · 결과 글
|
||||
// 앱 세션 · 앱 로그인 기록(계약 1절 "앱 로그인")은 세션의 client · 기록의 method 로만 가른다 — device 의 이름(User-Agent)으로 짐작하지 않는다
|
||||
|
||||
import {
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
END_OTHER_SESSIONS_NAME_LIMIT,
|
||||
END_OTHER_SESSIONS_TEXT,
|
||||
LOGIN_HISTORY_TEXT,
|
||||
MOBILE_APP_NAME,
|
||||
OTP_CODE_LENGTH,
|
||||
TWO_FACTOR_TEXT,
|
||||
} from '@/shared/constants/account-security'
|
||||
@@ -18,6 +20,7 @@ import type {
|
||||
LoginEvent,
|
||||
LoginMethod,
|
||||
MailClientProtocol,
|
||||
SessionClient,
|
||||
TwoFactorStatus,
|
||||
} from '@/shared/types/account-security'
|
||||
import { formatCount, formatLongDate, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
@@ -63,14 +66,52 @@ export function deviceLabel(device: LoginDevice | null): string {
|
||||
return os ? `${browser} · ${os}` : browser
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인된 기기 줄의 이름 — 웹 세션은 "Edge · Windows"(deviceLabel), 앱 세션은 "ARA SYSTEM 앱 · Android"(운영체제를 모르면 "ARA SYSTEM 앱").
|
||||
* 앱 이름은 client 로 붙인다 — 서버가 보낸 device 이름이 비어 있어도 "알 수 없는 브라우저"가 되지 않는다.
|
||||
* 줄의 [로그아웃] 이름 · 끊은 뒤 알림 · [다른 곳 모두 로그아웃] 확인 글이 함께 쓴다
|
||||
*/
|
||||
export function sessionDeviceLabel(session: AuthSession): string {
|
||||
if (session.client !== 'app') return deviceLabel(session.device)
|
||||
const os = session.device?.os?.trim()
|
||||
return os ? `${MOBILE_APP_NAME} · ${os}` : MOBILE_APP_NAME
|
||||
}
|
||||
|
||||
/** 휴대폰 아이콘을 쓸 운영체제 — 서버의 짧은 이름 (계약 5절 "로그인된 브라우저") */
|
||||
const MOBILE_OS = new Set(['iOS', 'Android'])
|
||||
|
||||
/** 로그인된 브라우저 줄의 아이콘 — 휴대폰 · PC (모르면 PC) */
|
||||
/** 브라우저의 운영체제가 휴대폰인가 — iOS · Android (모르면 아니다) */
|
||||
export function isMobileDevice(device: LoginDevice | null): boolean {
|
||||
return MOBILE_OS.has(device?.os ?? '')
|
||||
}
|
||||
|
||||
/** 로그인된 기기 줄의 아이콘 — 휴대폰 · PC. 앱 세션은 운영체제를 몰라도 휴대폰, 웹 세션은 운영체제로(모르면 PC) */
|
||||
export function isMobileSession(session: AuthSession): boolean {
|
||||
return session.client === 'app' || isMobileDevice(session.device)
|
||||
}
|
||||
|
||||
/** 세션 수를 client 로 나눠 센다 — 웹메일 · ARA SYSTEM 앱 */
|
||||
export function sessionCounts(sessions: readonly AuthSession[]): Record<SessionClient, number> {
|
||||
const counts: Record<SessionClient, number> = { web: 0, app: 0 }
|
||||
for (const session of sessions) {
|
||||
if (session.client === 'app') counts.app += 1
|
||||
else counts.web += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인된 기기의 개수 글 — 앱 세션이 없으면 "웹메일 3곳"(앱 1단계 전과 같다), 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳".
|
||||
* 0곳인 쪽은 쓰지 않는다(둘 다 0이면 "웹메일 0곳")
|
||||
*/
|
||||
export function sessionCountText(sessions: readonly AuthSession[]): string {
|
||||
const counts = sessionCounts(sessions)
|
||||
const parts: string[] = []
|
||||
if (counts.web > 0 || counts.app === 0) parts.push(LOGIN_HISTORY_TEXT.sessionsCountWeb(formatCount(counts.web)))
|
||||
if (counts.app > 0) parts.push(LOGIN_HISTORY_TEXT.sessionsCountApp(formatCount(counts.app)))
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 2단계 인증 상태 글 — 「꺼짐」 · 「2026년 10월 31일부터 켜짐」(켠 때를 모르면 「켜짐」) */
|
||||
export function twoFactorStateLabel(status: TwoFactorStatus): string {
|
||||
if (!status.isEnabled) return TWO_FACTOR_TEXT.off
|
||||
@@ -107,11 +148,12 @@ export function appPasswordLastUsedText(lastUsed: AppPasswordLastUsed | null, no
|
||||
|
||||
const METHOD_LABEL: Readonly<Record<LoginMethod, string>> = {
|
||||
webmail: LOGIN_HISTORY_TEXT.methodWebmail,
|
||||
app: LOGIN_HISTORY_TEXT.methodApp,
|
||||
imap: LOGIN_HISTORY_TEXT.methodImap,
|
||||
smtp: LOGIN_HISTORY_TEXT.methodSmtp,
|
||||
}
|
||||
|
||||
/** 로그인 기록의 방식 — "웹메일" · "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" */
|
||||
/** 로그인 기록의 방식 — "웹메일" · "ARA SYSTEM 앱" · "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" (모르는 값은 그대로) */
|
||||
export function loginMethodLabel(method: LoginMethod): string {
|
||||
return METHOD_LABEL[method] ?? method
|
||||
}
|
||||
@@ -121,9 +163,20 @@ export function loginAppPasswordLabel(event: LoginEvent): string | null {
|
||||
return event.appPassword ? LOGIN_HISTORY_TEXT.appPassword(event.appPassword.name) : null
|
||||
}
|
||||
|
||||
/** 로그인 기록의 기기 — 웹메일은 "Chrome · Windows", 메일 프로그램은 기기를 몰라 null(화면은 「—」) */
|
||||
/**
|
||||
* 로그인 기록의 기기 — 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 앱은 운영체제 "Android"
|
||||
* (방식 칸이 이미 "ARA SYSTEM 앱"이라 앱 이름을 되풀이하지 않는다 — 좁은 기기 칸에서 운영체제가 말줄임에 잘리지 않게).
|
||||
* 메일 프로그램 · 운영체제를 모르는 앱 줄은 null(화면은 「—」)
|
||||
*/
|
||||
export function loginDeviceLabel(event: LoginEvent): string | null {
|
||||
return event.method === 'webmail' ? deviceLabel(event.device) : null
|
||||
switch (event.method) {
|
||||
case 'webmail':
|
||||
return deviceLabel(event.device)
|
||||
case 'app':
|
||||
return event.device?.os?.trim() || null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,19 +199,19 @@ export function loginResultLabel(event: LoginEvent): { label: string; isFailure:
|
||||
}
|
||||
}
|
||||
|
||||
/** [다른 곳 모두 로그아웃]이 끊을 곳 — 지금 이 브라우저를 뺀 세션 */
|
||||
/** [다른 곳 모두 로그아웃]이 끊을 곳 — 지금 이 브라우저를 뺀 세션(웹메일 · 앱) */
|
||||
export function otherSessions(sessions: readonly AuthSession[]): AuthSession[] {
|
||||
return sessions.filter((session) => !session.isCurrent)
|
||||
}
|
||||
|
||||
/**
|
||||
* [다른 곳 모두 로그아웃] 확인 창 글 — "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …".
|
||||
* [다른 곳 모두 로그아웃] 확인 창 글 — "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 …".
|
||||
* 이름은 세 곳까지 늘어놓고 넘으면 "외 n곳".
|
||||
* 지금 이 브라우저 줄이 없으면(includesThisBrowser — 서버가 이 세션을 알아보지 못해 모두 끊는다) 이 브라우저도 로그아웃된다는 글
|
||||
*/
|
||||
export function endOtherSessionsMessage(others: readonly AuthSession[], includesThisBrowser = false): string {
|
||||
const count = others.length
|
||||
const names = others.slice(0, END_OTHER_SESSIONS_NAME_LIMIT).map((session) => deviceLabel(session.device))
|
||||
const names = others.slice(0, END_OTHER_SESSIONS_NAME_LIMIT).map((session) => sessionDeviceLabel(session))
|
||||
const rest = count - names.length
|
||||
const listed = rest > 0 ? `${names.join(', ')} ${END_OTHER_SESSIONS_TEXT.moreNames(formatCount(rest))}` : names.join(', ')
|
||||
return includesThisBrowser
|
||||
|
||||
@@ -12,7 +12,15 @@ import { useLoginHistoryStore } from './login-history.store'
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
function session(id: string, isCurrent = false): AuthSession {
|
||||
return { id, createdAt: '2026-10-31T00:00:00Z', lastActiveAt: '2026-10-31T00:00:00Z', ip: null, device: null, isCurrent }
|
||||
return {
|
||||
id,
|
||||
createdAt: '2026-10-31T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T00:00:00Z',
|
||||
ip: null,
|
||||
device: null,
|
||||
client: 'web',
|
||||
isCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
const pageOf = <T>(items: T[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
@@ -11,10 +11,10 @@ import type { LoadStatus } from '@/stores/mail.store'
|
||||
/** 최근 로그인의 보기 — 전체 · 실패만 */
|
||||
export type LoginEventFilter = 'all' | 'failures'
|
||||
|
||||
// 로그인 기록 스토어 — 계약 5절 "로그인된 브라우저" · "로그인 기록" (12단계)
|
||||
// 로그인 기록 스토어 — 계약 5절 "로그인된 브라우저" · "로그인 기록" (12단계), 앱 세션 · 앱 로그인 기록은 1절 "앱 로그인"
|
||||
// - 설정 › 로그인 기록에 들어올 때마다 둘 다 새로 받는다(loadSessions · loadEvents). 오류는 묶음마다 화면이 직접 그린다(사전 문구 + [다시 시도])
|
||||
// - 로그인된 브라우저: 줄의 [로그아웃]은 묻지 않고 바로 끊고(이미 끝난 세션 404 RES_001 도 끊긴 것과 같다) 줄을 뺀다.
|
||||
// [다른 곳 모두 로그아웃]은 이 요청의 세션만 남긴다 — 지금 세션 줄만 남긴다
|
||||
// - 로그인된 기기(웹메일 · 앱 세션): 줄의 [로그아웃]은 묻지 않고 바로 끊고(이미 끝난 세션 404 RES_001 도 끊긴 것과 같다) 줄을 뺀다.
|
||||
// [다른 곳 모두 로그아웃]은 이 요청의 세션만 남긴다(앱 세션도 끊는다) — 지금 세션 줄만 남긴다
|
||||
// - 최근 로그인: 20개씩 [더 보기](커서). [전체 · 실패만]을 바꾸면 그 보기로 처음부터 다시 받는다 — 받는 동안은 지난 목록을 그대로 두고(busy)
|
||||
// 다 받으면 바꾼다. 보이는 목록의 보기는 loadedFilter(머리 글 "실패 n번" · 빈 상태가 이것을 본다)
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq), 끊기는 세션 세대(authStore.generation)
|
||||
@@ -22,7 +22,7 @@ export const useLoginHistoryStore = defineStore('login-history', () => {
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── 로그인된 브라우저 ─────────────
|
||||
// ── 로그인된 기기 ─────────────
|
||||
const sessions = ref<AuthSession[]>([])
|
||||
const sessionsStatus = ref<LoadStatus>('idle')
|
||||
const sessionsError = ref<ApiError | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user