feat: 휴대폰 앱을 스토어 없이 사내에 나눠 주는 받기 화면 · API · 운영 도구 · 앱 서명 #22

Merged
ttipo merged 7 commits from feat/app-distribution into main 2026-11-09 09:45:52 +09:00
24 changed files with 2141 additions and 10 deletions
Showing only changes of commit 433ef12cc3 - Show all commits
+2 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR)
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR) · 설정 › 휴대폰 앱의 받기 주소(…/app — 비밀 아님)
// 칸은 shared/utils/qr-code.ts 가 고르고 여기서는 SVG <rect> 를 v-for 로 그린다 — v-html 을 쓰지 않는다.
// 바깥 여백(quiet zone)은 칸 2개 + 상자 안쪽 여백 — 읽는 앱이 그림의 끝을 알아보게 둔다.
// 이름(label)은 role="img" 의 aria-label — 그림의 내용(비밀이 든 주소)은 읽어 주지 않는다
@@ -8,7 +8,7 @@ import { computed } from 'vue'
import { qrModuleRuns } from '@/shared/utils/qr-code'
interface Props {
/** QR 에 담을 글 — otpauth 주소 */
/** QR 에 담을 글 — otpauth 주소 · 휴대폰 받기 화면 주소 */
value: string
/** 그림의 이름 (스크린리더) */
label: string
@@ -2,6 +2,7 @@
// 로그인 틀의 문의 줄 (13단계 — 디자인 README "13단계 › 문의 줄" · 계약 6절 "문의 연락처")
// 연락처가 있으면 물음(12.5px 회색) 다음 줄에 연락처(#344054 · 500), 없거나 받지 못했으면 지금 글 그대로(fallback).
// 연락처는 운영 도구로 넣는 설정값이다 — 글자로만 그린다(텍스트 보간 — 링크 · HTML 없음). 길어도 낱말 가운데서 끊지 않는다
// 휴대폰 앱 받기 화면(설정 › 휴대폰 앱 · /app)도 같은 꼴로 쓴다 — /app 은 본문이 14 라 글을 13 으로(size="base", 디자인 원본 SettingsMobileApp)
interface Props {
/** 물음 — "비밀번호를 잊으셨나요?" · "인증 앱을 쓸 수 없나요?" */
question: string
@@ -9,14 +10,18 @@ interface Props {
fallback: string
/** 연락처 한 줄 — 없으면 null */
contact: string | null
/** 글 크기 — label 12.5(기본) · base 13 */
size?: 'label' | 'base'
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
size: 'label',
})
</script>
<template>
<p
class="support-line"
:class="['support-line', { 'support-line--base': size === 'base' }]"
data-support-line
>
<template v-if="contact">
@@ -40,6 +45,10 @@ defineProps<Props>()
overflow-wrap: anywhere;
}
.support-line--base {
font-size: var(--font-size-base);
}
.support-line__contact {
color: var(--color-text-strong);
font-weight: 500;
@@ -0,0 +1,96 @@
<script setup lang="ts">
// [앱 내려받기] — 가장 늦게 올린 안드로이드 앱(APK)을 받는다 (디자인 원본 SettingsMobileApp)
// 첨부 받기처럼 <a href download> 이동이고, 누르기 직전에 세션을 확인한다(useAppReleases 의 downloadLatestAndroid — confirmSession).
// 세션이 끝났으면 세션 만료 창, 장애면 알림이 뜨고 이동하지 않는다. 확인하는 사이 다시 눌러도 한 번만 받는다.
// 가운데 버튼(새 탭)도 같은 흐름으로 받고, 오른쪽 버튼(메뉴 — 링크 주소 복사 등)은 브라우저에 맡긴다.
// 화면 낭독 이름은 크기까지 "앱 내려받기 (31 MB)" — 보이는 글로 시작한다.
// 설정의 앱 칸은 높이 44(글 13.5 · 아이콘 15), 휴대폰 받기 화면은 전체 폭 · 높이 48(block — 글 15 · 아이콘 18)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useAppReleases } from '@/composables/useAppReleases'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appDownloadLabel } from '@/shared/utils/app-release'
interface Props {
/** 크기 글 ("31 MB") — 모르면 빈 글 */
size: string
/** 휴대폰 받기 화면 — 전체 폭 · 높이 48 */
block?: boolean
}
const props = withDefaults(defineProps<Props>(), {
block: false,
})
const { latestAndroidFileUrl, downloadLatestAndroid } = useAppReleases()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const href = latestAndroidFileUrl()
const label = computed(() => appDownloadLabel(props.size))
/** 세션을 확인하는 중 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
let pending = false
function onDownload(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (pending) return
pending = true
void downloadLatestAndroid().finally(() => {
pending = false
})
}
</script>
<template>
<a
:class="['app-download', { 'app-download--block': block }]"
:href="href"
:aria-label="label"
download
data-app-download
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon
name="download"
:size="block ? 18 : 15"
:stroke-width="2"
/>
<span>{{ MOBILE_APP_TEXT.download }}</span>
</a>
</template>
<style scoped>
/* 채운 단추 — 청록 바탕 · 흰 글 (설정의 앱 칸: 높이 44 · 좌우 16 · 모서리 8) */
.app-download {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
text-decoration: none;
}
.app-download:hover {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
/* 휴대폰 받기 화면 — 전체 폭 · 높이 48 · 모서리 10 · 글 15 (44 보다 큰 보통 크기 — 밀도 예외가 아니다) */
.app-download--block {
display: flex;
gap: var(--space-8);
min-height: var(--space-48);
border-radius: var(--radius-lg);
font-size: var(--font-size-title-sm);
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
// 앱 그림 — 남색 바탕에 흰 ARA 마크 (디자인 원본 SettingsMobileApp — 홈 화면 아이콘 apple-touch-icon.png 를 둥글게 깎은 것이라 새 그림이 들지 않는다)
// 설정의 앱 칸은 48(모서리 12), 휴대폰 받기 화면의 카드는 52(모서리 13). 옆에 앱 이름이 있어 그림은 읽어 주지 않는다(alt="")
import appIcon from '@/assets/brand/apple-touch-icon.png'
interface Props {
/** 휴대폰 받기 화면의 카드 — 52 */
large?: boolean
}
withDefaults(defineProps<Props>(), {
large: false,
})
</script>
<template>
<img
:src="appIcon"
alt=""
width="180"
height="180"
:class="['app-tile', { 'app-tile--large': large }]"
>
</template>
<style scoped>
/* 모서리는 한 변의 1/4 — 48 은 12, 52 는 13 (보드 값) */
.app-tile {
flex: 0 0 auto;
display: block;
width: 3rem;
height: 3rem;
border-radius: 25%;
}
.app-tile--large {
width: 3.25rem;
height: 3.25rem;
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 아이폰 안내 상자 — 옅은 청록 바탕 · 안내 아이콘 · 제목(청록 굵게) + 글 (디자인 원본 SettingsMobileApp — 설정 화면 · 휴대폰 받기 화면이 같다)
// 「메일 프로그램 연결」은 설정 › 계정 › 메일 프로그램 연결로 가는 링크다(기본 메일 앱에 회사 메일을 잇는 차례가 있다). 글은 13 그대로
import AppIcon from '@/components/AppIcon.vue'
import LinkedText from '@/components/LinkedText.vue'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
const mailClientsRoute = { name: SETTINGS_ROUTES.mailClients }
</script>
<template>
<div
class="iphone-note"
data-app-iphone-note
>
<AppIcon
name="info"
class="iphone-note__icon"
/>
<div class="iphone-note__body">
<span class="iphone-note__title">{{ MOBILE_APP_TEXT.iphoneTitle }}</span>
<LinkedText
:before="MOBILE_APP_TEXT.iphoneBefore"
:link="MOBILE_APP_TEXT.iphoneLink"
:after="MOBILE_APP_TEXT.iphoneAfter"
:to="mailClientsRoute"
/>
</div>
</div>
</template>
<style scoped>
.iphone-note {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
font-size: var(--font-size-base);
}
.iphone-note__icon {
margin-top: var(--space-2);
color: var(--color-brand-strong);
}
.iphone-note__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.iphone-note__title {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 설치 차례 — 번호 줄(청록 굵은 숫자 · 동그라미 없음 — 2단계 인증 「켜는 차례」와 같은 꼴, 디자인 원본 SettingsMobileApp)
// 숫자는 꾸밈이라 읽어 주지 않는다(aria-hidden) — 목록(ol)의 차례로 읽힌다.
// 설정 화면은 줄 사이 8 · 숫자 칸 뒤 6, 휴대폰 받기 화면(roomy)은 줄 사이 10 · 숫자 칸 뒤 4 · 줄 높이 1.55
interface Props {
steps: readonly string[]
/** 휴대폰 받기 화면 */
roomy?: boolean
}
withDefaults(defineProps<Props>(), {
roomy: false,
})
</script>
<template>
<ol
:class="['app-steps', { 'app-steps--roomy': roomy }]"
data-app-steps
>
<li
v-for="(step, index) in steps"
:key="index"
class="app-steps__item"
>
<span
class="app-steps__number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</template>
<style scoped>
.app-steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.app-steps__item {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.app-steps__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.app-steps--roomy {
gap: var(--space-10);
line-height: 1.55;
}
.app-steps--roomy .app-steps__item {
gap: var(--space-4);
}
</style>
@@ -0,0 +1,40 @@
import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import { startBrowserDownload } from './useMail'
import type { AppRelease } from '@/shared/types/app-release'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type AppReleaseRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
const LATEST_ANDROID_PATH = '/app-releases/android/latest'
// 휴대폰 앱 받기 API Composable — 서버와 맞춘 약속(2026-11-08, 로그인 필요 · 제한된 세션은 막힌다)
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다.
// 파일은 첨부처럼 <a href> 이동으로 받는다(표준 래퍼 없음 — application/vnd.android.package-archive, attachment, Range)
export function useAppReleases() {
const api = useApi()
/** 가장 늦게 올린 안드로이드 판 — 올린 판이 없으면 404 RES_001 */
function getLatestAndroidRelease(options?: AppReleaseRequestOptions): Promise<AppRelease> {
return api.get<AppRelease, AppRelease>(LATEST_ANDROID_PATH, options)
}
/** 그 판의 파일 주소 — axios baseURL 과 같은 규칙으로 만들어야 쿠키(Path=/api)가 함께 간다 */
function latestAndroidFileUrl(): string {
return buildApiUrl(`${LATEST_ANDROID_PATH}/file`)
}
/**
* 앱(APK)을 내려받는다 — 첨부처럼 이동하기 전에 세션을 확인한다 (confirmSession).
* <a href> 이동은 axios 의 401 갱신 규칙을 타지 않아, 액세스 토큰이 만료된 채 이동하면 오류 JSON 이 파일로 받아진다.
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
*/
async function downloadLatestAndroid(): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(latestAndroidFileUrl())
return true
}
return { getLatestAndroidRelease, latestAndroidFileUrl, downloadLatestAndroid }
}
+10
View File
@@ -85,6 +85,16 @@ describe('LoginPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('휴대폰 받기 화면(QR 코드 · 앱의 [새 버전 받기])에서 왔으면 로그인한 뒤 /app 으로 돌아간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=/app')
await submit(wrapper, 'secret')
expect(router.currentRoute.value.name).toBe('mobile-app')
expect(router.currentRoute.value.fullPath).toBe('/app')
})
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MobileAppPage from './MobileAppPage.vue'
// 휴대폰 받기 화면 /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app")
// B1 받을 앱 있음 — 카드 · 설치 방법 · 아이폰 · 문의 줄 / B2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult
contact?: () => MockResult
me?: () => MockResult
}
describe('MobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/app')
await router.isReady()
const wrapper = mount(MobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
it('앱 틀 없이 브랜드 띠(로고 · 제목 · 설명)와 내용 — 레일 · 상단 바가 없다', async () => {
const wrapper = await mountPage()
const band = wrapper.get('header')
expect(band.get('img').attributes('alt')).toBe('ARA SYSTEM')
expect(band.get('h1').text()).toBe('휴대폰 앱 받기')
expect(band.text()).toContain('안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.')
expect(wrapper.find('main').exists()).toBe(true)
expect(wrapper.find('nav').exists()).toBe(false)
})
describe('B1 받을 앱 있음', () => {
it('카드 — 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기](전체 폭, 크기까지 읽어 준다)', async () => {
const wrapper = await mountPage()
const card = wrapper.get('[data-mobile-app-release]')
expect(card.text()).toContain('ARA SYSTEM')
expect(card.text()).toContain('안드로이드 앱')
const facts = card.get('dl[data-mobile-app-facts]')
expect(facts.findAll('dt').map((term) => term.text())).toEqual(['버전', '올린 날', '크기'])
expect(facts.findAll('dd').map((value) => value.text())).toEqual(['1.0.0', '2026년 11월 18일', '31 MB'])
const download = card.get('a[data-app-download]')
expect(download.classes()).toContain('app-download--block')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /app-releases/android/latest',
'GET /support-contact',
])
})
it('설치 방법 1~4 + 새 버전 한 줄 · 아이폰 안내 · 문의 줄(연락처가 있으면 "설치가 잘 되지 않나요?" 다음 줄에)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#app-page-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps.map((step) => step.text().slice(1))).toEqual([
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
])
expect(wrapper.text()).toContain(
'새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
)
expect(wrapper.get('[data-app-iphone-note] a').attributes('href')).toBe('/settings/account/mail-clients')
const line = wrapper.get('[data-support-line]')
expect(line.classes()).toContain('support-line--base')
expect(line.text()).toContain('설치가 잘 되지 않나요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
})
it('연락처가 없으면 문의 줄은 대신 쓰는 글 한 줄', async () => {
const wrapper = await mountPage({ contact: () => ok({ text: ' ' }) })
expect(wrapper.get('[data-support-line]').text()).toBe('설치가 잘 되지 않으면 관리자에게 문의해 주세요.')
})
it('[앱 내려받기] — 세션을 확인한 뒤 같은 주소로 받는다', async () => {
const wrapper = await mountPage()
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requests.map((request) => request.url)).toContain('/auth/me')
expect(downloads).toEqual([FILE_URL])
})
})
describe('B2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — 카드 · 설치 방법 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.get('h2').text()).toBe('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이 화면에서 받을 수 있습니다.')
expect(none.get('[data-support-line]').text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(none.get('[data-support-contact]').text()).toBe(CONTACT)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 없으면 "언제 받을 수 있는지는 관리자에게 문의해 주세요."', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(wrapper.get('[data-mobile-app-none] [data-support-line]').text()).toBe(
'언제 받을 수 있는지는 관리자에게 문의해 주세요.',
)
})
})
it('받지 못하면 카드 자리에 사전 문구와 [다시 시도] — 알리지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = 'network'
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('main [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.get('[data-mobile-app-release]').text()).toContain('1.0.0')
})
})
@@ -0,0 +1,398 @@
<script setup lang="ts">
// 휴대폰 받기 화면 — /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") · 탭 제목 "휴대폰 앱 받기 · ARA SYSTEM"
// 설정 화면의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 이 주소를 연다.
// 독립 화면: 앱 레일 · 상단 바가 없다(인쇄 화면처럼 앱 틀 밖의 보호 화면). 로그인해야 열린다 — 로그인하지 않은 브라우저는 라우터 가드가
// 지금의 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 이 화면으로 돌아온다(새 비밀번호 · 2단계 켜기 단계가 걸린 직원도 마친 뒤 돌아온다).
// - 브랜드 띠(#0B3640 — 로그인 브랜드 패널을 줄인 꼴: 로고 · 제목 · 설명) + 바탕 #F8F9FB 의 내용
// - 받을 앱 있음(B1): 카드(앱 그림 52 · 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기] 전체 폭 48) → 설치 방법 1~4 + 새 버전 한 줄 →
// 아이폰 안내 → 문의 줄("설치가 잘 되지 않나요?")
// - 받을 앱 없음(B2 — 404 RES_001, 알림 없음): 안내와 문의 줄("언제 받을 수 있는지 …")만
// - 받지 못함: 카드 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - [앱 내려받기]는 첨부처럼 누르기 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession). 세션이 끝났으면 세션 만료 창
// - 휴대폰 폭이 먼저다. 폭 480 보다 넓으면 띠는 화면 폭 전체로 두고 로고 · 제목 · 내용은 가운데 칸 480 에 맞춘다(B3)
import { computed } from 'vue'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import { APP_NAME } from '@/shared/constants/brand'
import { MOBILE_APP_PAGE_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appReleaseFacts, appReleaseSize } from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const facts = computed(() => (release.value ? appReleaseFacts(release.value) : []))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<div class="app-page">
<header class="app-page__band">
<div class="app-page__band-inner">
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
<img
:src="logoFull"
:alt="APP_NAME"
width="960"
height="459"
class="app-page__logo"
>
<div class="app-page__intro">
<h1 class="app-page__title">
{{ MOBILE_APP_PAGE_TEXT.title }}
</h1>
<p class="app-page__lead">
{{ MOBILE_APP_PAGE_TEXT.lead }}
</p>
</div>
</div>
</header>
<main class="app-page__main">
<div class="app-page__column">
<!-- 받을 앱 있음 -->
<template v-if="release">
<div
class="app-page__card"
data-mobile-app-release
>
<div class="app-page__card-head">
<MobileAppIcon large />
<div class="app-page__card-name">
<span class="app-page__name">{{ MOBILE_APP_TEXT.appName }}</span>
<span class="app-page__platform">{{ MOBILE_APP_PAGE_TEXT.platform }}</span>
</div>
</div>
<dl
class="app-page__facts"
data-mobile-app-facts
>
<template
v-for="fact in facts"
:key="fact.label"
>
<dt class="app-page__fact-label">
{{ fact.label }}
</dt>
<dd class="app-page__fact-value">
{{ fact.value }}
</dd>
</template>
</dl>
<MobileAppDownloadLink
:size="size"
block
/>
</div>
<section
class="app-page__steps"
aria-labelledby="app-page-steps-title"
>
<h2
id="app-page-steps-title"
class="app-page__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps
:steps="MOBILE_APP_PAGE_TEXT.steps"
roomy
/>
<p class="app-page__update">
{{ MOBILE_APP_PAGE_TEXT.updateText }}
</p>
</section>
<MobileAppIphoneNote />
<div class="app-page__support">
<LoginSupportLine
:question="MOBILE_APP_PAGE_TEXT.supportQuestion"
:fallback="MOBILE_APP_PAGE_TEXT.supportFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</template>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="app-page__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="28"
:stroke-width="1.5"
class="app-page__none-icon"
/>
<h2 class="app-page__none-title">
{{ MOBILE_APP_TEXT.noneTitle }}
</h2>
<p class="app-page__none-text">
{{ MOBILE_APP_PAGE_TEXT.noneText }}
</p>
<div class="app-page__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</div>
<!-- 받지 못함 — 카드 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="app-page__placeholder"
aria-busy="true"
/>
</div>
</main>
</div>
</template>
<style scoped>
/* 휴대폰 폭(360)이 먼저다 — 넓은 화면(480 초과)은 아래 미디어 쿼리 */
.app-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-surface-panel);
color: var(--color-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
/* 브랜드 띠 — 로그인 브랜드 패널을 줄인 꼴 (로고 120 · 제목 20 · 설명) */
.app-page__band {
padding: var(--space-28) var(--space-20) var(--space-24);
background: var(--color-brand-surface);
}
.app-page__band-inner {
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.app-page__logo {
display: block;
width: 7.5rem;
height: auto;
}
.app-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.app-page__title {
margin: 0;
font-size: var(--font-size-heading);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
color: var(--color-on-brand);
}
.app-page__lead {
margin: 0;
font-size: var(--font-size-body);
line-height: 1.6;
color: var(--color-on-brand-muted);
}
/* 내용 — 본문 14, 가운데 칸 480 */
.app-page__main {
flex: 1 1 auto;
padding: var(--space-20) var(--space-16) var(--space-28);
font-size: var(--font-size-body);
}
.app-page__column {
width: 100%;
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-22);
}
/* 앱 카드 — 테두리 · 모서리 12 · 안쪽 16 */
.app-page__card {
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__card-head {
display: flex;
align-items: center;
gap: var(--space-12);
}
.app-page__card-name {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.app-page__name {
font-size: var(--font-size-title-md);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.app-page__platform {
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
/* 버전 · 올린 날 · 크기 — 이름 칸 56 */
.app-page__facts {
margin: 0;
display: grid;
grid-template-columns: var(--space-56) minmax(0, 1fr);
row-gap: var(--space-2);
font-size: var(--font-size-base);
font-variant-numeric: tabular-nums;
}
.app-page__fact-label {
color: var(--color-text-muted);
}
.app-page__fact-value {
margin: 0;
color: var(--color-text-strong);
}
.app-page__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.app-page__section-title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.app-page__update {
margin: var(--space-2) 0 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
/* 문의 줄 — 위에 가는 선 */
.app-page__support {
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 28 · 제목 17 · 안내 · 가는 선 아래 문의 줄 */
.app-page__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20) var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__none-icon {
color: var(--color-icon-muted);
}
.app-page__none-title {
margin: 0;
font-size: var(--font-size-title-md);
line-height: var(--line-height-heading);
font-weight: 650;
}
.app-page__none-text {
margin: 0;
color: var(--color-text-strong);
}
.app-page__none-support {
align-self: stretch;
margin-top: var(--space-4);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.app-page__placeholder {
min-height: 16rem;
}
/* 넓은 화면(480 초과 — PC 브라우저) — 띠는 화면 폭 전체, 로고 · 제목 · 내용은 가운데 칸 480 (B3) */
@media (min-width: 30.0625rem) {
/* 띠 위 36 · 좌우 24 · 아래 32 */
.app-page__band {
padding: var(--space-36) var(--space-24) 2rem;
}
.app-page__band-inner {
gap: var(--space-18);
}
.app-page__logo {
width: 9.375rem;
}
.app-page__title {
font-size: var(--font-size-heading-lg);
}
.app-page__main {
padding: var(--space-28) var(--space-24) var(--space-40);
}
.app-page__card {
padding: var(--space-20);
}
.app-page__none {
padding: var(--space-24) var(--space-20);
}
}
</style>
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import QrCodeImage from '@/components/QrCodeImage.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountMobileAppPage from './AccountMobileAppPage.vue'
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱")
// A1 받을 앱 있음 — 앱 칸 · QR · 설치 방법 · 새 버전 · 아이폰 / A2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult | Promise<MockResult>
contact?: () => MockResult
me?: () => MockResult | Promise<MockResult>
refresh?: () => MockResult
}
describe('AccountMobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
/** 실제 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
case '/auth/refresh':
return (replies.refresh ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/settings/account/mobile-app')
await router.isReady()
const wrapper = mount(AccountMobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
describe('A1 받을 앱 있음', () => {
it('머리 · 「안드로이드 앱」 칸 — 앱 이름과 "버전 · 올린 날 · 크기" 한 줄, [앱 내려받기]는 크기까지 읽어 준다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('휴대폰 앱')
expect(wrapper.text()).toContain(
'안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
)
expect(wrapper.get('#mobile-app-android-title').text()).toBe('안드로이드 앱')
const box = wrapper.get('[data-mobile-app-release]')
expect(box.text()).toContain('ARA SYSTEM')
expect(box.get('[data-mobile-app-facts]').text()).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
const download = box.get('a[data-app-download]')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(download.attributes('download')).toBe('')
expect(requestLines()).toEqual(['GET /app-releases/android/latest', 'GET /support-contact'])
})
it('QR 은 지금 주소의 출처 + /app 만 담는다 — 휴대폰으로 받기 · 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄', async () => {
const wrapper = await mountPage()
const qr = wrapper.getComponent(QrCodeImage)
expect(qr.props('value')).toBe(`${window.location.origin}/app`)
expect(wrapper.get('[data-qr-code]').attributes('aria-label')).toBe('휴대폰에서 앱을 받는 주소의 QR 코드')
const phone = wrapper.get('[data-mobile-app-phone]')
expect(phone.get('h3').text()).toBe('휴대폰으로 받기')
expect(phone.text()).toContain(
'휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
)
expect(phone.text()).toContain('QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.')
expect(phone.get('[data-mobile-app-address]').text()).toBe(`${window.location.host}/app`)
expect(phone.text()).toContain('PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.')
})
it('설치 방법 1~4(둘째 줄에 받는 파일 이름) · 새 버전이 나오면 · 아이폰 안내(메일 프로그램 연결 링크)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#mobile-app-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps).toHaveLength(4)
expect(steps.map((step) => step.find('[aria-hidden="true"]').text())).toEqual(['1', '2', '3', '4'])
expect(steps[1]?.text()).toContain('받은 ‘ARA-SYSTEM-1.0.0.apk’ 파일을 열어 주세요.')
expect(steps[3]?.text()).toContain('앱 비밀번호는 만들지 않으셔도 됩니다.')
expect(wrapper.get('#mobile-app-update-title').text()).toBe('새 버전이 나오면')
expect(wrapper.text()).toContain('꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.')
const iphone = wrapper.get('[data-app-iphone-note]')
expect(iphone.text()).toContain('아이폰용 앱은 아직 없습니다')
expect(iphone.get('a').text()).toBe('메일 프로그램 연결')
expect(iphone.get('a').attributes('href')).toBe('/settings/account/mail-clients')
// 받을 앱이 있으면 문의 줄은 없다
expect(wrapper.find('[data-support-line]').exists()).toBe(false)
})
it('받는 동안은 칸 자리만 둔다 (aria-busy) — QR · 설치 방법은 받은 뒤에', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
expect(wrapper.find('section [aria-busy="true"]').exists()).toBe(true)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
})
})
describe('A2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — QR · 설치 방법 · 새 버전 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.text()).toContain('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이곳에서 받아 설치할 수 있습니다.')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 있으면 물음 다음 줄에 연락처', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const line = wrapper.get('[data-mobile-app-none] [data-support-line]')
expect(line.text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
expect(line.find('br').exists()).toBe(true)
})
it('연락처를 정하지 않았거나 받지 못하면 대신 쓰는 글 한 줄', async () => {
const unset = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(unset.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(unset.find('[data-support-contact]').exists()).toBe(false)
unset.unmount()
setActivePinia((pinia = createPinia()))
useAuthStore().markAuthenticated(USER)
const failed = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => fail(503, 'SYS_001') })
expect(failed.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(useToastStore().toasts).toEqual([])
})
})
describe('받지 못함', () => {
it('칸 자리에 사전 문구와 [다시 시도] — 알림은 띄우지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('section [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(alert.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
expect(requestLines().filter((line) => line.endsWith('/latest'))).toHaveLength(2)
})
})
describe('[앱 내려받기] — 이동 전 세션 확인', () => {
it('누르면 세션을 확인한 뒤 같은 주소로 받는다 — 원래 링크의 이동은 막고, 확인 중에 다시 눌러도 한 번만', async () => {
let releaseMe: (result: MockResult) => void = () => {}
const meGate = new Promise<MockResult>((settle) => {
releaseMe = settle
})
const wrapper = await mountPage({ me: () => meGate })
const link = wrapper.get<HTMLAnchorElement>('a[data-app-download]').element
const first = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(first)
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(first.defaultPrevented).toBe(true)
expect(downloads).toEqual([])
releaseMe(ok(USER))
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me'])
expect(downloads).toEqual([FILE_URL])
})
it('세션이 끝났으면 받지 않고 세션 만료 창으로 (갱신도 실패)', async () => {
const wrapper = await mountPage({ me: () => fail(401, 'AUTH_001'), refresh: () => fail(401, 'AUTH_001') })
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me', 'POST /auth/refresh'])
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
})
it('세션이 바뀐 뒤 늦게 온 앱 정보는 쓰지 않는다', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
useAuthStore().clearAuth()
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
})
})
@@ -0,0 +1,383 @@
<script setup lang="ts">
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — /settings/account/mobile-app · 탭 제목 "휴대폰 앱"
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. PC 에서 보는 화면이다.
// 머리 + 설명 → 「안드로이드 앱」 칸:
// - 받을 앱 있음(A1): 앱 그림 · 이름 · "버전 · 올린 날 · 크기" · [앱 내려받기] / 아래 줄에 QR(지금 주소의 출처 + /app — 비밀 아님) · 「휴대폰으로 받기」 ·
// 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄. 그 뒤 「설치 방법」 1~4 · 「새 버전이 나오면」 · 아이폰 안내(메일 프로그램 연결 링크)
// - 받을 앱 없음(A2 — 404 RES_001, 알림 없음): 안내와 문의 줄만(문의 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 대신 쓰는 글 한 줄)
// - 받지 못함: 칸 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - 들어올 때마다 새로 받는다(app-release.store — 운영자가 새 판을 올렸을 수 있다). 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
// - [앱 내려받기]는 첨부처럼 이동 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession)
// - 좁은 폭(휴대폰 — 480 이하): 설정 메뉴는 위로(지금 설정 셸), QR 줄은 빼고 [앱 내려받기]는 이름 아래 전체 폭
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import QrCodeImage from '@/components/QrCodeImage.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MOBILE_APP_SETTINGS_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import {
addressWithoutScheme,
appReleaseFactsLine,
appReleaseFileName,
appReleaseSize,
mobileAppPageUrl,
} from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** QR 이 담는 주소 — 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
const pageUrl = mobileAppPageUrl(window.location.origin)
/** 휴대폰 브라우저의 주소 칸에 넣을 글 ("portal.arasystem.it.kr/app") */
const pageAddress = addressWithoutScheme(pageUrl)
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const factsLine = computed(() => (release.value ? appReleaseFactsLine(release.value) : ''))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const steps = computed(() => MOBILE_APP_SETTINGS_TEXT.steps(appReleaseFileName(release.value?.version ?? '')))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<SettingsPageFrame
title-id="mobile-app-page-title"
:title="MOBILE_APP_SETTINGS_TEXT.title"
:description="MOBILE_APP_SETTINGS_TEXT.description"
>
<section
class="mobile-app__section"
aria-labelledby="mobile-app-android-title"
>
<h2
id="mobile-app-android-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.androidTitle }}
</h2>
<!-- 받을 앱 있음 — 앱 줄 + QR 줄 -->
<div
v-if="release"
class="mobile-app__box"
data-mobile-app-release
>
<div class="mobile-app__app">
<MobileAppIcon />
<div class="mobile-app__app-text">
<span class="mobile-app__app-name">{{ MOBILE_APP_TEXT.appName }}</span>
<p
class="mobile-app__facts"
data-mobile-app-facts
>
{{ factsLine }}
</p>
</div>
<MobileAppDownloadLink
class="mobile-app__download"
:size="size"
/>
</div>
<div
class="mobile-app__phone"
data-mobile-app-phone
>
<div class="mobile-app__qr-frame">
<QrCodeImage
:value="pageUrl"
:label="MOBILE_APP_SETTINGS_TEXT.qrLabel"
/>
</div>
<div class="mobile-app__phone-text">
<h3 class="mobile-app__phone-title">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTitle }}
</h3>
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneScan }}
</p>
<div class="mobile-app__typing">
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTyping }}
</p>
<p
class="mobile-app__address"
data-mobile-app-address
>
{{ pageAddress }}
</p>
</div>
<p class="mobile-app__pc-note">
{{ MOBILE_APP_SETTINGS_TEXT.pcNote }}
</p>
</div>
</div>
</div>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="mobile-app__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="24"
:stroke-width="1.5"
class="mobile-app__none-icon"
/>
<div class="mobile-app__none-text">
<span class="mobile-app__none-title">{{ MOBILE_APP_TEXT.noneTitle }}</span>
<span class="mobile-app__none-hint">{{ MOBILE_APP_SETTINGS_TEXT.noneText }}</span>
</div>
<div class="mobile-app__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
/>
</div>
</div>
<!-- 받지 못함 — 칸 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="mobile-app__placeholder"
aria-busy="true"
/>
</section>
<template v-if="release">
<section
class="mobile-app__steps"
aria-labelledby="mobile-app-steps-title"
>
<h2
id="mobile-app-steps-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps :steps="steps" />
</section>
<section
class="mobile-app__update"
aria-labelledby="mobile-app-update-title"
>
<h2
id="mobile-app-update-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.updateTitle }}
</h2>
<p
v-for="text in MOBILE_APP_SETTINGS_TEXT.updateTexts"
:key="text"
class="mobile-app__copy"
>
{{ text }}
</p>
</section>
<MobileAppIphoneNote />
</template>
</SettingsPageFrame>
</template>
<style scoped>
.mobile-app__section {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mobile-app__section-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
/* 앱 칸 — 테두리 · 모서리 10, 앱 줄과 QR 줄 사이에 가는 선 */
.mobile-app__box {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 앱 줄 — 그림 48 · 이름 · 정보 · [앱 내려받기](44). 좁으면 단추가 이름 아래 전체 폭으로 내려간다 */
.mobile-app__app {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-14);
padding: var(--space-16);
}
.mobile-app__app-text {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
line-height: var(--line-height-heading);
}
.mobile-app__app-name {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__facts {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.mobile-app__download {
flex: 0 0 auto;
}
/* QR 줄 — QR 160(상자 안쪽 6 · 테두리) + 글 */
.mobile-app__phone {
display: flex;
align-items: flex-start;
gap: var(--space-20);
padding: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__qr-frame {
flex: 0 0 auto;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.mobile-app__phone-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-2);
}
.mobile-app__phone-title {
margin: 0;
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.mobile-app__copy {
margin: 0;
color: var(--color-text-strong);
}
.mobile-app__typing {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: var(--space-10);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__address {
margin: 0;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--color-text);
}
.mobile-app__pc-note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 24 · 제목 · 안내 · 가는 선 아래 문의 줄 */
.mobile-app__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.mobile-app__none-icon {
color: var(--color-icon-muted);
}
.mobile-app__none-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mobile-app__none-title {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__none-hint {
color: var(--color-text-secondary);
}
.mobile-app__none-support {
align-self: stretch;
margin-top: var(--space-2);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__placeholder {
min-height: 16rem;
}
.mobile-app__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.mobile-app__update {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 좁은 폭(휴대폰) — QR 줄은 빼고(휴대폰에서는 [앱 내려받기]로 바로 받는다) 단추는 이름 아래 전체 폭 */
@media (max-width: 30rem) {
.mobile-app__phone {
display: none;
}
.mobile-app__download {
flex: 1 1 100%;
}
}
</style>
@@ -9,8 +9,9 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -35,7 +36,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -48,6 +49,7 @@ describe('SettingsLayout', () => {
['2단계 인증', '/settings/account/two-factor'],
['앱 비밀번호', '/settings/account/app-passwords'],
['메일 프로그램 연결', '/settings/account/mail-clients'],
['휴대폰 앱', '/settings/account/mobile-app'],
['로그인 기록', '/settings/account/login-history'],
],
[
@@ -145,6 +147,24 @@ describe('SettingsLayout', () => {
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
})
it('설정 › 계정 › 휴대폰 앱 — /settings/account/mobile-app, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/account/mobile-app')
expect(router.currentRoute.value.name).toBe('settings-account-mobile-app')
expect(router.currentRoute.value.meta).toMatchObject({ title: '휴대폰 앱', requiresAuth: true })
expect(router.resolve({ name: 'settings-account-mobile-app' }).fullPath).toBe('/settings/account/mobile-app')
})
it('휴대폰 앱 메뉴는 휴대폰 아이콘(smartphone) · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/account/mobile-app')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('휴대폰 앱')
expect(current.find('rect').attributes()).toMatchObject({ x: '5', y: '2', width: '14', height: '20' })
expect(current.find('line').attributes()).toMatchObject({ x1: '12', y1: '18', x2: '12.01', y2: '18' })
})
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/large-files')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
+24
View File
@@ -44,6 +44,30 @@ describe('router 가드', () => {
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
})
// 휴대폰 받기 화면 /app (2026-11-08 — 디자인 README "휴대폰 앱 받기") — 앱 틀 밖의 보호 화면. QR 코드 · 앱의 [새 버전 받기]가 연다
it('휴대폰 받기 화면(/app)은 로그인해야 열린다 — 세션이 없으면 로그인으로, 돌아올 곳은 /app', async () => {
const requests = installMockApi(() => fail(401, 'AUTH_001'))
await router.push('/app')
expect(requests.map((request) => request.url)).toEqual(['/auth/me', '/auth/refresh'])
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/app')
})
it('로그인해 둔 브라우저는 /app 을 바로 연다(탭 제목 "휴대폰 앱 받기 · ARA SYSTEM") — 로그인 화면의 redirect=/app 은 /app 으로 돌려보낸다', async () => {
installMockApi(() => ok(USER))
useAuthStore().markAuthenticated(USER)
await router.push('/app')
expect(router.currentRoute.value.name).toBe('mobile-app')
expect(router.currentRoute.value.meta).toMatchObject({ requiresAuth: true, title: '휴대폰 앱 받기' })
expect(document.title).toBe('휴대폰 앱 받기 · ARA SYSTEM')
await router.push('/login?redirect=/app')
expect(router.currentRoute.value.fullPath).toBe('/app')
})
// 제한된 세션 (13단계 — 계약 6절) — 로그인은 됐지만 새 비밀번호 · 2단계 켜기를 마칠 때까지 보호 화면 대신 로그인 틀의 그 단계
it('제한된 세션이면 보호 화면 대신 로그인으로 — 돌아올 곳은 가려던 주소', async () => {
installMockApi(() => ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
+23 -1
View File
@@ -3,6 +3,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
import { PUBLIC_SHARE_ROUTE } from '@/shared/constants/large-file'
import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
import {
MOBILE_APP_PAGE_TEXT,
MOBILE_APP_PATH,
MOBILE_APP_ROUTE,
MOBILE_APP_SETTINGS_TEXT,
} from '@/shared/constants/mobile-app'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import {
MAIL_SCHEDULED_MESSAGE_ROUTE,
@@ -41,6 +47,15 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/mail/MailPrintPage.vue'),
meta: { requiresAuth: true, title: '메일 인쇄' },
},
{
// 휴대폰 받기 화면 (2026-11-08 — 디자인 README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") — 앱 틀 없이 안드로이드 앱(APK) 받기.
// 설정 › 휴대폰 앱의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 연다. 로그인해야 열린다 — 로그인하지 않았으면
// 가드가 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 돌아온다. 경로는 앱도 쓰는 약속이다(MOBILE_APP_PATH)
path: MOBILE_APP_PATH,
name: MOBILE_APP_ROUTE,
component: () => import('@/pages/mobile-app/MobileAppPage.vue'),
meta: { requiresAuth: true, title: MOBILE_APP_PAGE_TEXT.title },
},
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
@@ -96,7 +111,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -129,6 +144,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/AccountMailClientsPage.vue'),
meta: { title: '메일 프로그램 연결' },
},
{
// 휴대폰 앱 (2026-11-08 — 디자인 README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — 안드로이드 앱(APK) 받기 · QR · 설치 방법
path: 'account/mobile-app',
name: SETTINGS_ROUTES.mobileApp,
component: () => import('@/pages/settings/AccountMobileAppPage.vue'),
meta: { title: MOBILE_APP_SETTINGS_TEXT.title },
},
{
path: 'account/login-history',
name: SETTINGS_ROUTES.loginHistory,
@@ -0,0 +1,93 @@
// 휴대폰 앱 받기 — 설정 › 계정 › 휴대폰 앱(PC)과 휴대폰 받기 화면 /app 의 글 (2026-11-08 — 디자인 원본 SettingsMobileApp · README "휴대폰 앱 받기")
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. 글은 확정한 보드(캔버스 v68)에서 그대로 옮겼고,
// 이제부터 이 상수가 글의 기준이다 — 고칠 때는 README 의 그 절도 함께 고친다 (README "화면 문구 — 사용자에게 말하듯").
// 서버가 거절한 까닭의 문구는 에러 코드 사전에서만 온다(받지 못함 · 다시 시도 — 화면 글로 만들지 않는다).
import { APP_NAME } from '@/shared/constants/brand'
/**
* 휴대폰 받기 화면의 주소 — 설정 화면의 QR 코드가 담고(지금 주소의 출처 + 이 경로), 앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]가 연다
* (앱은 API_URL 에서 /api 를 뗀 출처 + 이 경로 — 같은 주소 배포). 바꾸면 앱도 함께 바꾼다
*/
export const MOBILE_APP_PATH = '/app'
/** 휴대폰 받기 화면의 라우트 이름 */
export const MOBILE_APP_ROUTE = 'mobile-app'
/** 받는 파일 이름 "ARA-SYSTEM-<버전>.apk" — 서버의 Content-Disposition 과 같은 꼴 (설치 차례 글에만 쓴다 — 실제 이름은 서버가 정한다) */
export const APP_RELEASE_FILE_PREFIX = 'ARA-SYSTEM-'
export const APP_RELEASE_FILE_EXTENSION = '.apk'
/** 두 화면이 함께 쓰는 글 */
export const MOBILE_APP_TEXT = {
/** 앱 이름 — 앱 칸 · 받기 화면의 카드 */
appName: APP_NAME,
/** 앱 정보의 이름 — 설정은 "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB" 한 줄, 받기 화면은 이름 · 값 두 칸 */
versionLabel: '버전',
releasedOnLabel: '올린 날',
sizeLabel: '크기',
/** 받기 단추 */
download: '앱 내려받기',
/** 받기 단추의 화면 낭독 이름 — "앱 내려받기 (31 MB)" (크기를 모르면 단추 글 그대로) */
downloadLabel: (size: string) => `앱 내려받기 (${size})`,
stepsTitle: '설치 방법',
/** 올린 앱이 없을 때 (404 RES_001) */
noneTitle: '아직 받을 수 있는 앱이 없습니다',
/** 올린 앱이 없을 때의 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 fallback 한 줄 */
noneQuestion: '언제 받을 수 있는지 궁금하신가요?',
noneFallback: '언제 받을 수 있는지는 관리자에게 문의해 주세요.',
/** 아이폰 안내 상자 — 「메일 프로그램 연결」이 그 설정 화면 링크 */
iphoneTitle: '아이폰용 앱은 아직 없습니다',
iphoneBefore: '아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 ',
iphoneLink: '메일 프로그램 연결',
iphoneAfter: '에서 볼 수 있습니다.',
} as const
/** 설정 › 계정 › 휴대폰 앱 (PC) — 보드 A */
export const MOBILE_APP_SETTINGS_TEXT = {
/** 메뉴 이름 · 탭 제목 · 머리 */
title: '휴대폰 앱',
description: '안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
androidTitle: '안드로이드 앱',
/** QR 코드의 화면 낭독 이름 — 담은 주소는 비밀이 아니다 */
qrLabel: '휴대폰에서 앱을 받는 주소의 QR 코드',
phoneTitle: '휴대폰으로 받기',
phoneScan: '휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
phoneTyping: 'QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.',
pcNote: 'PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.',
/** 설치 차례 — 받는 파일 이름("ARA-SYSTEM-1.0.0.apk")을 둘째 줄에 넣는다 */
steps: (fileName: string): readonly string[] => [
'휴대폰에서 [앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 이 파일을 받을지 한 번 더 물으면 그대로 받아 주세요.',
`받은 ‘${fileName}’ 파일을 열어 주세요. 처음 설치할 때는 휴대폰이 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 돌아와 설치를 이어 가 주세요.`,
'Play 스토어를 거치지 않은 앱이라 Play 프로텍트가 경고할 수 있습니다. 웹메일에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요. 앱 비밀번호는 만들지 않으셔도 됩니다.',
],
updateTitle: '새 버전이 나오면',
updateTexts: [
'새 버전도 같은 곳에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
'꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.',
],
/** 올린 앱이 없을 때 — 제목은 MOBILE_APP_TEXT.noneTitle */
noneText: '앱이 준비되면 이곳에서 받아 설치할 수 있습니다.',
} as const
/** 휴대폰 받기 화면 /app — 보드 B (탭 제목 "휴대폰 앱 받기 · ARA SYSTEM") */
export const MOBILE_APP_PAGE_TEXT = {
/** 탭 제목 · 브랜드 띠의 제목 */
title: '휴대폰 앱 받기',
lead: '안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
/** 카드의 앱 이름 아래 */
platform: '안드로이드 앱',
steps: [
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
],
/** 설치 방법 아래 한 줄 — 앱의 [새 버전 받기]로 온 직원이 쓰던 앱을 먼저 지우지 않게 */
updateText: '새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
/** 받을 앱이 있을 때의 문의 줄 */
supportQuestion: '설치가 잘 되지 않나요?',
supportFallback: '설치가 잘 되지 않으면 관리자에게 문의해 주세요.',
/** 올린 앱이 없을 때 — 제목은 MOBILE_APP_TEXT.noneTitle */
noneText: '앱이 준비되면 이 화면에서 받을 수 있습니다.',
} as const
@@ -1,10 +1,12 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결)
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결 ·
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크)가 이 이름을 함께 쓴다.
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크 · 휴대폰 앱의 아이폰 안내)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
import { MOBILE_APP_SETTINGS_TEXT } from '@/shared/constants/mobile-app'
export const SETTINGS_ROUTES = {
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
@@ -14,6 +16,8 @@ export const SETTINGS_ROUTES = {
appPasswords: 'settings-account-app-passwords',
/** 메일 프로그램 연결 — 설정값 · 프로그램별 연결 차례 (13단계) */
mailClients: 'settings-account-mail-clients',
/** 휴대폰 앱 — 안드로이드 앱(APK) 받기 · QR · 설치 방법 (2026-11-08) */
mobileApp: 'settings-account-mobile-app',
loginHistory: 'settings-account-login-history',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
@@ -49,6 +53,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.twoFactor, label: '2단계 인증', icon: 'shield-check' },
{ routeName: SETTINGS_ROUTES.appPasswords, label: '앱 비밀번호', icon: 'key' },
{ routeName: SETTINGS_ROUTES.mailClients, label: '메일 프로그램 연결', icon: 'monitor-smartphone' },
{ routeName: SETTINGS_ROUTES.mobileApp, label: MOBILE_APP_SETTINGS_TEXT.title, icon: 'smartphone' },
{ routeName: SETTINGS_ROUTES.loginHistory, label: '로그인 기록', icon: 'history' },
],
},
+12
View File
@@ -0,0 +1,12 @@
// 휴대폰 앱 받기 API 공유 타입 — 서버와 맞춘 약속(2026-11-08 — 계약에 새 절로 들어간다)과 같은 구조
// GET /api/app-releases/android/latest(로그인 필요) — 올린 판이 없으면 404 RES_001. 파일은 같은 경로의 /file(<a href> 이동으로 받는다)
/** 가장 늦게 올린 안드로이드 판 — "최신"은 올린 시각 차례다(버전 차례가 아니다 — 옛 판을 다시 올리면 그것이 최신) */
export interface AppRelease {
/** "1.0.0" */
version: string
/** 파일 크기 (바이트) */
sizeBytes: number
/** 올린 때 — ISO 8601 UTC */
releasedAt: string
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import type { AppRelease } from '@/shared/types/app-release'
import {
addressWithoutScheme,
appDownloadLabel,
appReleaseFacts,
appReleaseFactsLine,
appReleaseFileName,
appReleaseSize,
mobileAppPageUrl,
} from './app-release'
// 휴대폰 앱 받기 — 앱 정보 글 (디자인 원본 SettingsMobileApp: "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB")
const SEOUL = 'Asia/Seoul'
const UTC = 'UTC'
/** 31 MB — 서버 약속의 예시 값 */
const RELEASE: AppRelease = { version: '1.0.0', sizeBytes: 32505856, releasedAt: '2026-11-18T01:23:45.000Z' }
describe('appReleaseFacts', () => {
it('버전 · 올린 날(연도까지 쓴 날짜) · 크기(첨부와 같은 표기)', () => {
expect(appReleaseFacts(RELEASE, SEOUL)).toEqual([
{ label: '버전', value: '1.0.0' },
{ label: '올린 날', value: '2026년 11월 18일' },
{ label: '크기', value: '31 MB' },
])
})
it('올린 날은 시간대의 날짜다 — 한국 아침 7시에 올린 판은 UTC 로는 전날', () => {
const early: AppRelease = { ...RELEASE, releasedAt: '2026-11-17T22:00:00.000Z' }
expect(appReleaseFacts(early, SEOUL)[1]).toEqual({ label: '올린 날', value: '2026년 11월 18일' })
expect(appReleaseFacts(early, UTC)[1]).toEqual({ label: '올린 날', value: '2026년 11월 17일' })
})
it('읽을 수 없는 값은 칸째 뺀다 — 빈 버전 · 날짜가 아님 · 음수 크기', () => {
expect(appReleaseFacts({ version: ' ', sizeBytes: -1, releasedAt: 'not-a-date' }, SEOUL)).toEqual([])
expect(appReleaseFacts({ ...RELEASE, releasedAt: '' }, SEOUL).map((fact) => fact.label)).toEqual(['버전', '크기'])
})
it('버전 앞뒤 공백은 뗀다', () => {
expect(appReleaseFacts({ ...RELEASE, version: ' 1.2.0 ' }, SEOUL)[0]).toEqual({ label: '버전', value: '1.2.0' })
})
})
describe('appReleaseFactsLine', () => {
it('설정 화면의 한 줄 — 칸을 가운뎃점으로 잇는다', () => {
expect(appReleaseFactsLine(RELEASE, SEOUL)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
expect(appReleaseFactsLine(RELEASE, UTC)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
})
it('뺀 칸은 가운뎃점도 없다', () => {
expect(appReleaseFactsLine({ ...RELEASE, sizeBytes: Number.NaN }, SEOUL)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일')
})
})
describe('크기 · 받기 단추 이름 · 파일 이름', () => {
it('크기 — 1MB 넘으면 MB, 10 미만은 소수 한 자리', () => {
expect(appReleaseSize(RELEASE)).toBe('31 MB')
expect(appReleaseSize({ ...RELEASE, sizeBytes: 9.5 * 1024 * 1024 })).toBe('9.5 MB')
})
it('[앱 내려받기]의 화면 낭독 이름 — 크기를 모르면 단추 글 그대로', () => {
expect(appDownloadLabel('31 MB')).toBe('앱 내려받기 (31 MB)')
expect(appDownloadLabel('')).toBe('앱 내려받기')
})
it('받는 파일 이름 — ARA-SYSTEM-<버전>.apk (서버의 Content-Disposition 과 같은 꼴)', () => {
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk')
expect(appReleaseFileName(' 1.0.1 ')).toBe('ARA-SYSTEM-1.0.1.apk')
})
})
describe('휴대폰 받기 화면 주소', () => {
it('지금 주소의 출처 + /app — QR 이 담는다', () => {
expect(mobileAppPageUrl('https://portal.arasystem.it.kr')).toBe('https://portal.arasystem.it.kr/app')
expect(mobileAppPageUrl('http://localhost:5173/')).toBe('http://localhost:5173/app')
})
it('주소 칸에 넣을 글은 앞의 https:// 를 뗀다', () => {
expect(addressWithoutScheme('https://portal.arasystem.it.kr/app')).toBe('portal.arasystem.it.kr/app')
expect(addressWithoutScheme('http://localhost:5173/app')).toBe('localhost:5173/app')
expect(addressWithoutScheme('portal.arasystem.it.kr/app')).toBe('portal.arasystem.it.kr/app')
})
})
+61
View File
@@ -0,0 +1,61 @@
// 휴대폰 앱 받기 — 앱 정보를 화면 글로 다듬는다 (설정 › 계정 › 휴대폰 앱 · 휴대폰 받기 화면 /app — 순수 함수만 둔다)
import {
APP_RELEASE_FILE_EXTENSION,
APP_RELEASE_FILE_PREFIX,
MOBILE_APP_PATH,
MOBILE_APP_TEXT,
} from '@/shared/constants/mobile-app'
import type { AppRelease } from '@/shared/types/app-release'
import { formatFileSize, formatLongDate } from '@/shared/utils/format'
/** 앱 정보 한 칸 — 이름(버전 · 올린 날 · 크기)과 값 */
export interface AppReleaseFact {
label: string
value: string
}
/**
* 앱 정보 — 버전 · 올린 날 · 크기. 올린 날은 연도까지 쓴 날짜("2026년 11월 18일" — 다른 설정 화면처럼 브라우저 시간대),
* 크기는 첨부와 같은 표기("31 MB"). 읽을 수 없는 값(빈 버전 · 날짜가 아님 · 크기가 음수)은 칸째 뺀다
*/
export function appReleaseFacts(release: AppRelease, timeZone?: string): AppReleaseFact[] {
const facts: AppReleaseFact[] = [
{ label: MOBILE_APP_TEXT.versionLabel, value: release.version.trim() },
{ label: MOBILE_APP_TEXT.releasedOnLabel, value: formatLongDate(release.releasedAt, timeZone) },
{ label: MOBILE_APP_TEXT.sizeLabel, value: appReleaseSize(release) },
]
return facts.filter((fact) => fact.value !== '')
}
/** 설정 화면의 한 줄 — "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB" (칸 구분의 가운뎃점 — 나열이 아니다) */
export function appReleaseFactsLine(release: AppRelease, timeZone?: string): string {
return appReleaseFacts(release, timeZone)
.map((fact) => `${fact.label} ${fact.value}`)
.join(' · ')
}
/** 크기 글 — "31 MB". 읽을 수 없으면 빈 글 */
export function appReleaseSize(release: AppRelease): string {
return formatFileSize(release.sizeBytes)
}
/** [앱 내려받기]의 화면 낭독 이름 — "앱 내려받기 (31 MB)", 크기를 모르면 단추 글 그대로 */
export function appDownloadLabel(size: string): string {
return size ? MOBILE_APP_TEXT.downloadLabel(size) : MOBILE_APP_TEXT.download
}
/** 받는 파일 이름 — "ARA-SYSTEM-1.0.0.apk" (서버의 Content-Disposition 과 같은 꼴) */
export function appReleaseFileName(version: string): string {
return `${APP_RELEASE_FILE_PREFIX}${version.trim()}${APP_RELEASE_FILE_EXTENSION}`
}
/** 휴대폰 받기 화면의 주소 — QR 이 담는다. 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
export function mobileAppPageUrl(origin: string): string {
return `${origin.replace(/\/+$/, '')}${MOBILE_APP_PATH}`
}
/** 주소 칸에 그대로 넣을 글 — 앞의 https:// · http:// 를 뗀다 ("portal.arasystem.it.kr/app") */
export function addressWithoutScheme(url: string): string {
return url.replace(/^https?:\/\//i, '')
}
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockResult,
} from '@/test-utils/mock-api'
import { useAppReleaseStore } from './app-release.store'
// 휴대폰 앱 받기 — 가장 늦게 올린 안드로이드 판 (GET /api/app-releases/android/latest)
// 404 RES_001 은 "받을 앱 없음"(알림 없음), 그 밖의 실패는 화면이 칸 자리에 사전 문구와 [다시 시도] — 알림은 모두 끈다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const RELEASE: AppRelease = { version: '1.0.0', sizeBytes: 32505856, releasedAt: '2026-11-18T01:23:45.000Z' }
/** 응답을 손으로 보내는 요청 — 늦게 온 응답을 흉내 낸다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('app-release.store', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('받을 앱이 있으면 ready — 그 경로를 알림을 끈 채 묻는다', async () => {
const requests = installMockApi(() => ok(RELEASE))
const store = useAppReleaseStore()
await store.load()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /app-releases/android/latest'])
expect(requests[0]?.config.silent).toBe(true)
expect(store.status).toBe('ready')
expect(store.release).toEqual(RELEASE)
expect(store.error).toBeNull()
})
it('404 RES_001 은 실패가 아니라 "받을 앱 없음" — 알리지 않는다', async () => {
installMockApi(() => fail(404, 'RES_001'))
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('none')
expect(store.release).toBeNull()
expect(store.error).toBeNull()
expect(useToastStore().toasts).toEqual([])
})
it('그 밖의 실패는 error — 사전 문구 그대로 상태에 담고 알리지 않는다(화면이 [다시 시도]를 그린다)', async () => {
installMockApi(() => fail(503, 'SYS_001'))
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('error')
expect(store.error?.code).toBe('SYS_001')
expect(store.error?.traceId).toBe('3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d')
expect(useToastStore().toasts).toEqual([])
})
it('네트워크가 끊겨도 error — 다시 받으면 그 결과로 바뀐다', async () => {
let reply: MockResult = 'network'
installMockApi(() => reply)
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('error')
reply = ok(RELEASE)
const loading = store.load()
expect(store.status).toBe('loading')
expect(store.error).toBeNull()
await loading
expect(store.status).toBe('ready')
})
it('세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 — 받아 둔 값도 지운다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useAppReleaseStore()
const loading = store.load()
expect(store.status).toBe('loading')
// 받는 사이에 로그아웃했다
useAuthStore().clearAuth()
reply.resolve(ok(RELEASE))
await loading
await flushPromises()
expect(store.status).toBe('idle')
expect(store.release).toBeNull()
})
it('늦게 온 실패도 세션이 바뀌었으면 쓰지 않는다 (다른 계정으로 로그인)', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useAppReleaseStore()
const loading = store.load()
useAuthStore().startSession({ email: 'seojun.park@arasystem.it.kr', name: '박서준' })
reply.resolve(fail(503, 'SYS_001'))
await loading
await flushPromises()
expect(store.status).toBe('idle')
expect(store.error).toBeNull()
})
it('다시 받기가 시작됐으면 앞의 늦은 응답은 버린다', async () => {
const first = deferredReply()
let calls = 0
installMockApi(() => {
calls += 1
return calls === 1 ? first.promise : fail(404, 'RES_001')
})
const store = useAppReleaseStore()
const stale = store.load()
await store.load()
expect(store.status).toBe('none')
first.resolve(ok(RELEASE))
await stale
expect(store.status).toBe('none')
expect(store.release).toBeNull()
})
})
+70
View File
@@ -0,0 +1,70 @@
import { ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useAppReleases } from '@/composables/useAppReleases'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
/**
* 받을 앱 정보의 상태 — idle(받기 전) · loading · ready(받을 앱 있음) · none(올린 앱 없음 — 404 RES_001) · error(받지 못함 — 화면이 사전 문구와 [다시 시도])
*/
export type AppReleaseStatus = 'idle' | 'loading' | 'ready' | 'none' | 'error'
// 휴대폰 앱 받기 스토어 — 가장 늦게 올린 안드로이드 판 (GET /api/app-releases/android/latest)
// - 설정 › 계정 › 휴대폰 앱 · 휴대폰 받기 화면 /app 이 들어올 때마다 새로 받는다(운영자가 새 판을 올렸을 수 있다).
// - 알림은 모두 끈다 — 404 RES_001 은 실패가 아니라 "받을 앱 없음"이고, 그 밖의 실패는 화면이 칸 자리에 사전 문구와 [다시 시도]를 그린다
// (세션 만료 대화상자는 silent 와 상관없이 뜬다).
// - 늦게 온 응답은 쓰지 않는다 — 다시 받기(seq)가 시작됐거나 세션 세대(authStore.generation — 로그아웃 · 만료 · 다른 계정 로그인)가 바뀌었으면 버린다.
// 세션이 바뀌면 받아 둔 값도 지운다
export const useAppReleaseStore = defineStore('app-release', () => {
const api = useAppReleases()
const authStore = useAuthStore()
const release = shallowRef<AppRelease | null>(null)
const status = ref<AppReleaseStatus>('idle')
const error = ref<ApiError | null>(null)
let seq = 0
/** 새로 받는다 — 결과는 상태로만 남긴다(던지지 않는다) */
async function load(): Promise<void> {
seq += 1
const current = seq
const session = authStore.generation
status.value = 'loading'
error.value = null
const stale = () => current !== seq || session !== authStore.generation
try {
const result = await api.getLatestAndroidRelease({ silent: true })
if (stale()) return
release.value = result
status.value = 'ready'
} catch (caught) {
if (stale()) return
const failure = toApiError(caught)
release.value = null
if (failure.code === 'RES_001') {
status.value = 'none'
return
}
status.value = 'error'
error.value = failure
}
}
function reset() {
seq += 1
release.value = null
status.value = 'idle'
error.value = null
}
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 앞 세션의 응답 · 값이 남지 않게
watch(
() => authStore.generation,
() => reset(),
)
return { release, status, error, load, reset }
})
+1
View File
@@ -22,6 +22,7 @@
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
--font-size-title-md: 1.0625rem; /* 17px — 휴대폰 앱 받기 화면(/app)의 앱 이름 · 받을 앱이 없을 때의 제목 (디자인 원본 SettingsMobileApp) */
--font-size-title-lg: 1.125rem; /* 18px — 설정 화면 제목 (디자인 원본 Settings) */
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
+8
View File
@@ -71,6 +71,14 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 앱 틀 밖의 휴대폰 받기 화면 (router/routes.ts 와 같은 이름 · 모양)
{
path: '/settings/account/mobile-app',
name: 'settings-account-mobile-app',
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/app', name: 'mobile-app', component: Empty, meta: { requiresAuth: true } },
{
path: '/settings/account/login-history',
name: 'settings-account-login-history',