diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt index 109726666..f1d470f3a 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt @@ -2,4 +2,11 @@ package io.deck.iam.api interface OAuthProviderQuery { fun isProviderConfigured(providerType: String): Boolean + + fun getGoogleSiteVerification(): GoogleSiteVerificationSnapshot? + + data class GoogleSiteVerificationSnapshot( + val metaVerificationToken: String? = null, + val htmlVerificationFileName: String? = null, + ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt new file mode 100644 index 000000000..df85b3d6b --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt @@ -0,0 +1,41 @@ +package io.deck.iam.controller + +import io.deck.iam.api.OAuthProviderQuery +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +/** + * Google Site Verification (HTML 파일 확인 방식) + * + * Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다. + * 설정은 OAuthProviderConfig.Google.WebsiteVerification에서 관리한다. + */ +@RestController +@PreAuthorize("permitAll()") +class GoogleSiteVerificationController( + private val oauthProviderQuery: OAuthProviderQuery, +) { + @GetMapping("/{filename:google[a-z0-9]+\\.html}") + fun verify( + @PathVariable filename: String, + ): ResponseEntity { + val snapshot = oauthProviderQuery.getGoogleSiteVerification() + ?: return ResponseEntity.notFound().build() + + val expected = snapshot.htmlVerificationFileName + ?: return ResponseEntity.notFound().build() + + if (filename != expected) { + return ResponseEntity.notFound().build() + } + + return ResponseEntity + .ok() + .contentType(MediaType.TEXT_PLAIN) + .body("google-site-verification: $expected") + } +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt index 02d6dbee0..d080977bc 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt @@ -58,7 +58,7 @@ fun OAuthProviderEntity.toDto(): OAuthProviderDto = fun UpdateOAuthProviderRequest.toConfig(provider: OAuthProviderType): OAuthProviderConfig? = when (provider) { - OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google + OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google() OAuthProviderType.NAVER -> OAuthProviderConfig.Naver OAuthProviderType.KAKAO -> OAuthProviderConfig.Kakao OAuthProviderType.OKTA -> domain?.let { OAuthProviderConfig.Okta(it) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt index 7c30d76ac..345990017 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt @@ -22,8 +22,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo JsonSubTypes.Type(value = OAuthProviderConfig.Aip::class, name = "AIP"), ) sealed class OAuthProviderConfig { - /** Google: 추가 설정 없음 */ - data object Google : OAuthProviderConfig() + /** Google: 사이트 검증 설정 */ + data class Google( + val websiteVerification: WebsiteVerification? = null, + ) : OAuthProviderConfig() { + data class WebsiteVerification( + val metaVerificationToken: String? = null, + val htmlVerificationFileName: String? = null, + ) + } /** Naver: 추가 설정 없음 */ data object Naver : OAuthProviderConfig() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt index 9b49871b6..a9c7337eb 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt @@ -1,6 +1,7 @@ package io.deck.iam.service import io.deck.iam.api.OAuthProviderQuery +import io.deck.iam.domain.OAuthProviderConfig import io.deck.iam.domain.OAuthProviderType import org.springframework.stereotype.Service @@ -12,4 +13,13 @@ class OAuthProviderQueryImpl( val type = OAuthProviderType.entries.find { it.name == providerType } ?: return false return oauthProviderService.get(type)?.isConfigured() ?: false } + + override fun getGoogleSiteVerification(): OAuthProviderQuery.GoogleSiteVerificationSnapshot? { + val entity = oauthProviderService.get(OAuthProviderType.GOOGLE) ?: return null + val verification = (entity.config as? OAuthProviderConfig.Google)?.websiteVerification ?: return null + return OAuthProviderQuery.GoogleSiteVerificationSnapshot( + metaVerificationToken = verification.metaVerificationToken, + htmlVerificationFileName = verification.htmlVerificationFileName, + ) + } } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt index d7b7f5d73..a4e10aabd 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt @@ -65,14 +65,15 @@ class OAuthProviderService( oauthProviderRepository.findById(type).orElseGet { OAuthProviderEntity(provider = type) } + val mergedConfig = mergeProviderConfig(type, entity.config, config) // 활성화된 Provider는 필수값 검증 if (enabled) { - val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, config) + val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, mergedConfig) validateProviderConfig(req, entity) } - entity.update(enabled, clientId, clientSecret, config) + entity.update(enabled, clientId, clientSecret, mergedConfig) // Lockout 방지 검증 validateOwnerCanLogin(entity) @@ -94,13 +95,14 @@ class OAuthProviderService( oauthProviderRepository.findById(req.type).orElseGet { OAuthProviderEntity(provider = req.type) } + val mergedConfig = mergeProviderConfig(req.type, entity.config, req.config) // 활성화된 Provider는 필수값 검증 if (req.enabled) { - validateProviderConfig(req, entity) + validateProviderConfig(req.copy(config = mergedConfig), entity) } - entity.update(req.enabled, req.clientId, req.clientSecret, req.config) + entity.update(req.enabled, req.clientId, req.clientSecret, mergedConfig) entity } @@ -201,6 +203,28 @@ class OAuthProviderService( val config: OAuthProviderConfig?, ) + private fun mergeProviderConfig( + type: OAuthProviderType, + existingConfig: OAuthProviderConfig?, + requestConfig: OAuthProviderConfig?, + ): OAuthProviderConfig? = + when (type) { + OAuthProviderType.GOOGLE -> { + val existingGoogle = existingConfig as? OAuthProviderConfig.Google + val requestGoogle = requestConfig as? OAuthProviderConfig.Google + + when { + requestGoogle == null -> existingGoogle + requestGoogle.websiteVerification != null -> requestGoogle + else -> requestGoogle.copy(websiteVerification = existingGoogle?.websiteVerification) + } + } + + else -> { + requestConfig + } + } + private fun publishProviderActivity(entity: OAuthProviderEntity) { eventPublisher.publishEvent( currentUserActivityEvent( diff --git a/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt index 24c352a33..cd4ef58b7 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt @@ -23,7 +23,7 @@ class DynamicClientRegistrationRepositoryTest : enabled = true, clientId = "google-id", clientSecret = "google-secret", - config = OAuthProviderConfig.Google, + config = OAuthProviderConfig.Google(), ) val repository = DynamicClientRegistrationRepository(oauthProviderRepository) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt new file mode 100644 index 000000000..ff1de6390 --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt @@ -0,0 +1,78 @@ +package io.deck.iam.controller + +import io.deck.iam.api.OAuthProviderQuery +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType + +class GoogleSiteVerificationControllerTest : + DescribeSpec({ + lateinit var oauthProviderQuery: OAuthProviderQuery + lateinit var controller: GoogleSiteVerificationController + + beforeEach { + oauthProviderQuery = mockk(relaxed = true) + controller = GoogleSiteVerificationController(oauthProviderQuery) + } + + describe("Google Site Verification") { + it("설정된 파일명과 일치하면 검증 응답을 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ) + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.OK + response.headers.contentType shouldBe MediaType.TEXT_PLAIN + response.body shouldBe "google-site-verification: google12cfc68677988bb4.html" + } + + it("파일명이 일치하지 않으면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ) + + // when + val response = controller.verify("googleabc123.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + + it("검증 설정이 없으면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns null + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + + it("htmlVerificationFileName이 null이면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + metaVerificationToken = "some-token", + htmlVerificationFileName = null, + ) + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt index 014b56022..1f16e6340 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt @@ -719,6 +719,95 @@ class OAuthProviderServiceTest : result.size shouldBe 2 verify { oauthProviderRepository.saveAll(any>()) } } + + it("Google provider 일괄 저장 시 기존 사이트 검증 설정을 유지해야 한다") { + // given + val owner = createOwner() + val googleIdentity = createIdentity(owner, AuthProvider.GOOGLE) + val settings = SystemSettingEntity(internalLoginEnabled = false) + val existingGoogle = + OAuthProviderEntity( + provider = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "google-secret", + config = + OAuthProviderConfig.Google( + websiteVerification = + OAuthProviderConfig.Google.WebsiteVerification( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ), + ), + ) + + every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle) + every { oauthProviderRepository.saveAll(any>()) } answers { firstArg() } + every { systemSettingService.getSettings() } returns settings + every { userRepository.findOwners() } returns listOf(owner) + every { identityService.findAllByUserId(owner.id) } returns listOf(googleIdentity) + + // when + val result = + oauthProviderService.saveAll( + listOf( + OAuthProviderService.ProviderSaveRequest( + type = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "", + config = OAuthProviderConfig.Google(), + ), + ), + ) + + // then + (result.single().config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe + "google12cfc68677988bb4.html" + } + } + + describe("Google 사이트 검증 설정 보존") { + it("Google provider 단건 저장 시 기존 사이트 검증 설정을 유지해야 한다") { + // given + val owner = createOwner() + val identity = createIdentity(owner, AuthProvider.GOOGLE) + val settings = SystemSettingEntity(internalLoginEnabled = false) + val existingGoogle = + OAuthProviderEntity( + provider = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "google-secret", + config = + OAuthProviderConfig.Google( + websiteVerification = + OAuthProviderConfig.Google.WebsiteVerification( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ), + ), + ) + + every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle) + every { oauthProviderRepository.findAll() } returns emptyList() + every { oauthProviderRepository.save(any()) } answers { firstArg() } + every { systemSettingService.getSettings() } returns settings + every { userRepository.findOwners() } returns listOf(owner) + every { identityService.findAllByUserId(owner.id) } returns listOf(identity) + + // when + val result = + oauthProviderService.save( + type = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "", + config = OAuthProviderConfig.Google(), + ) + + // then + (result.config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe + "google12cfc68677988bb4.html" + } } describe("activity log 이벤트 발행") { diff --git a/frontend/app/src/app/App.tsx b/frontend/app/src/app/App.tsx index d8fdc9c2d..b736c1401 100644 --- a/frontend/app/src/app/App.tsx +++ b/frontend/app/src/app/App.tsx @@ -34,6 +34,7 @@ import { AuthorizationProvider } from '#app/shared/authorization'; import { auth } from '#app/features/auth'; import { OverlayProvider } from '#app/shared/overlay'; +const LandingPage = lazy(() => import('#app/pages/landing/landing.page')); const LoginPage = lazy(() => import('#app/pages/login/login.page')); const InvitePage = lazy(() => import('#app/pages/invite/invite.page')); const WorkspaceInvitePage = lazy(() => import('#app/pages/workspace-invite/workspace-invite.page')); @@ -149,6 +150,7 @@ function StandalonePageRoute({ pathname }: { pathname: string }) { } const standaloneRoutes: RouteObject[] = [ + { path: '/', element: withStandaloneFallback() }, { path: '/login/*', element: withStandaloneFallback() }, { path: '/invite/*', element: withStandaloneFallback() }, { path: '/workspace-invite/*', element: withStandaloneFallback() }, diff --git a/frontend/app/src/app/app.test.tsx b/frontend/app/src/app/app.test.tsx index bda89d9fb..3fe4953cf 100644 --- a/frontend/app/src/app/app.test.tsx +++ b/frontend/app/src/app/app.test.tsx @@ -132,6 +132,9 @@ describe('App overlay dismiss 통합', () => { sidebarCollapsed.set(false); isInitialized.set(true); isLoading.set(false); + + // / 경로는 LandingPage를 렌더링하므로 AppShell 테스트는 /dashboard에서 시작 + window.history.replaceState({}, '', '/dashboard'); }); afterEach(() => { diff --git a/frontend/app/src/pages/about/about.page.tsx b/frontend/app/src/pages/about/about.page.tsx index cd36380f0..77d2027b2 100644 --- a/frontend/app/src/pages/about/about.page.tsx +++ b/frontend/app/src/pages/about/about.page.tsx @@ -1,16 +1,18 @@ import { useTranslation } from '#app/shared/i18n'; +import { usePublicAuthConfig } from '#app/shared/public-auth-config'; import { LegalLinks } from '#app/shared/legal-links/legal-links'; import { StandaloneLayout } from '#app/layouts'; export function AboutPage() { const { t } = useTranslation('common'); + const { brandName } = usePublicAuthConfig(); return (

{t('legal.updated')}

-

{t('legal.about.title')}

+

{brandName}

{t('legal.about.summary')}

@@ -18,7 +20,9 @@ export function AboutPage() {
-

{t('legal.about.sections.identity.title')}

+

+ {t('legal.about.sections.identity.title')} +

{t('legal.about.sections.identity.body')}

@@ -30,13 +34,17 @@ export function AboutPage() {

-

{t('legal.about.sections.calendar.title')}

+

+ {t('legal.about.sections.calendar.title')} +

{t('legal.about.sections.calendar.body')}

-

{t('legal.about.sections.contact.title')}

+

+ {t('legal.about.sections.contact.title')} +

{t('legal.about.sections.contact.body')}

diff --git a/frontend/app/src/pages/landing/landing.page.test.tsx b/frontend/app/src/pages/landing/landing.page.test.tsx new file mode 100644 index 000000000..ae8b046a2 --- /dev/null +++ b/frontend/app/src/pages/landing/landing.page.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { LandingPage } from './landing.page'; +import { http } from '#app/shared/http-client'; + +vi.mock('#app/shared/http-client', () => ({ + http: { + get: vi.fn(), + }, +})); + +vi.mock('#app/shared/public-auth-config', () => ({ + usePublicAuthConfig: () => ({ + brandName: 'Deck', + contactEmail: 'privacy@deck.io', + }), +})); + +vi.mock('#app/shared/i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => + ( + { + 'landing.signIn': 'Sign In', + 'landing.signUp': 'Sign Up', + 'landing.getStarted': 'Get Started', + 'landing.learnMore': 'Learn More', + 'legal.about.summary': 'Deck summary', + 'legal.about.sections.identity.title': 'Identity', + 'legal.about.sections.identity.body': 'Identity body', + 'legal.about.sections.oauth.title': 'OAuth', + 'legal.about.sections.oauth.body': 'OAuth body', + 'legal.about.sections.calendar.title': 'Calendar', + 'legal.about.sections.calendar.body': 'Calendar body', + 'legal.about.sections.contact.title': 'Contact', + 'legal.about.sections.contact.body': 'Contact body', + } as Record + )[key] ?? key, + }), +})); + +vi.mock('#app/layouts', () => ({ + StandaloneLayout: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('#app/components/ui/button', async () => { + const { cloneElement, createElement: h, isValidElement } = await import('react'); + + return { + Button: function MockButton(props: Record) { + const buttonProps = props as React.HTMLAttributes & { + children?: ReactNode; + asChild?: boolean; + }; + const { children, asChild, ...rest } = buttonProps; + + if (asChild && isValidElement(children)) { + return cloneElement(children, rest); + } + + return h('button', rest, children); + }, + }; +}); + +vi.mock('#app/shared/legal-links/legal-links', () => ({ + LegalLinks: () =>
Legal Links
, +})); + +vi.mock('#app/shared/branding', () => ({ + BrandLogo: ({ className }: { className?: string }) => Brand logo, +})); + +describe('LandingPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + (http.get as Mock).mockRejectedValue(new Error('Unauthorized')); + }); + + afterEach(() => { + cleanup(); + }); + + it('회원가입 CTA는 깨진 초대 수락 페이지가 아닌 로그인 경로를 가리켜야 한다', async () => { + render( + + + + ); + + const signUpLink = await waitFor(() => screen.getByRole('link', { name: 'Sign Up' })); + + expect(signUpLink.getAttribute('href')).toBe('/login'); + }); +}); diff --git a/frontend/app/src/pages/landing/landing.page.tsx b/frontend/app/src/pages/landing/landing.page.tsx new file mode 100644 index 000000000..a4434742b --- /dev/null +++ b/frontend/app/src/pages/landing/landing.page.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { StandaloneLayout } from '#app/layouts'; +import { Button } from '#app/components/ui/button'; +import { usePublicAuthConfig } from '#app/shared/public-auth-config'; +import { useTranslation } from '#app/shared/i18n'; +import { LegalLinks } from '#app/shared/legal-links/legal-links'; +import { BrandLogo } from '#app/shared/branding'; +import { http } from '#app/shared/http-client'; +import type { CurrentUser } from '#app/entities/user'; + +export function LandingPage() { + const { t } = useTranslation('common'); + const { brandName } = usePublicAuthConfig(); + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + let cancelled = false; + + http + .get('/auth/me', { + skipAuthRedirect: true, + showToast: false, + showProgress: false, + }) + .then(() => { + if (!cancelled) navigate('/dashboard', { replace: true }); + }) + .catch(() => { + if (!cancelled) setChecking(false); + }); + + return () => { + cancelled = true; + }; + }, [navigate]); + + if (checking) return null; + + return ( + +
+ {/* Header */} +
+ +
+ + {/* Hero */} +
+
+
+

{brandName}

+

+ {t('legal.about.summary')} +

+ +
+ + {/* Feature grid */} +
+ {(['identity', 'oauth', 'calendar', 'contact'] as const).map((key) => ( +
+

+ {t(`legal.about.sections.${key}.title`)} +

+

+ {t(`legal.about.sections.${key}.body`)} +

+
+ ))} +
+
+
+ + {/* Footer */} + +
+
+ ); +} + +export default LandingPage; diff --git a/frontend/app/src/shared/i18n/locales/en/common.json b/frontend/app/src/shared/i18n/locales/en/common.json index 48a1ae374..23d90daf6 100644 --- a/frontend/app/src/shared/i18n/locales/en/common.json +++ b/frontend/app/src/shared/i18n/locales/en/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "Toggle Maximize", "maximizeRequiresTab": "Select a menu first" + }, + "landing": { + "signIn": "Sign In", + "signUp": "Sign Up", + "getStarted": "Get Started", + "learnMore": "Learn More" } } diff --git a/frontend/app/src/shared/i18n/locales/ja/common.json b/frontend/app/src/shared/i18n/locales/ja/common.json index 4a5165c11..42aad5e6e 100644 --- a/frontend/app/src/shared/i18n/locales/ja/common.json +++ b/frontend/app/src/shared/i18n/locales/ja/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "最大化切り替え", "maximizeRequiresTab": "先にメニューを選択してください" + }, + "landing": { + "signIn": "ログイン", + "signUp": "新規登録", + "getStarted": "始める", + "learnMore": "詳しく見る" } } diff --git a/frontend/app/src/shared/i18n/locales/ko/common.json b/frontend/app/src/shared/i18n/locales/ko/common.json index e0b56b729..2f636e7a2 100644 --- a/frontend/app/src/shared/i18n/locales/ko/common.json +++ b/frontend/app/src/shared/i18n/locales/ko/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "최대화 토글", "maximizeRequiresTab": "메뉴를 먼저 선택해주세요" + }, + "landing": { + "signIn": "로그인", + "signUp": "회원가입", + "getStarted": "시작하기", + "learnMore": "자세히 보기" } }