forked from baron/baron-sso
Merge branch 'dev' into fix/rebac-env-sync-issue
This commit is contained in:
@@ -44,9 +44,11 @@ missing = "No active session was found."
|
||||
greeting = "Hello, {name}."
|
||||
|
||||
[msg.userfront.audit]
|
||||
browser = "Browser: {value}"
|
||||
date = "Date: {value}"
|
||||
device = "Device: {value}"
|
||||
end = "No more items to show."
|
||||
filtered_empty = "No sign-in history matches the active session filter."
|
||||
ip = "IP address: {value}"
|
||||
load_more_error = "Could not load more history."
|
||||
result = "Result: {value}"
|
||||
@@ -84,6 +86,7 @@ client_id = "Client ID: {id}"
|
||||
client_id_missing = "No client ID available."
|
||||
current_status = "Current status: {status}"
|
||||
last_auth = "Last signed in: {value}"
|
||||
link_status = "Link status: {status}"
|
||||
link_missing = "This app does not have a launch URL configured."
|
||||
link_open_error = "Could not open the app link."
|
||||
render_error = "Dashboard render error: {error}"
|
||||
@@ -94,6 +97,20 @@ empty = "No linked apps yet."
|
||||
empty_detail = "Linked apps and their latest activity will appear here."
|
||||
error = "Could not load linked apps."
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = "Browser: {value}"
|
||||
empty = "No active sessions."
|
||||
empty_detail = "Devices signed in with this account will appear here."
|
||||
error = "Could not load sessions."
|
||||
os = "OS: {value}"
|
||||
recent_app = "Recent app: {app}"
|
||||
session_id = "Session ID: {id}"
|
||||
|
||||
[msg.userfront.dashboard.sessions.revoke]
|
||||
confirm = "End the session for {target}?\nThat device will need to sign in again."
|
||||
error = "Could not end the session: {error}"
|
||||
success = "The session has been ended."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
|
||||
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
|
||||
@@ -270,6 +287,7 @@ uppercase = "At least one uppercase letter"
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = "Your linked apps and their latest sign-in status."
|
||||
audit_subtitle = "Recent access history for Baron sign-in."
|
||||
sessions_subtitle = "Your currently signed-in devices and browser sessions."
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = "Account settings are currently unavailable."
|
||||
@@ -420,8 +438,10 @@ dev_console = "Dev Console"
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = "Action"
|
||||
app = "App"
|
||||
auth_method = "Auth Method"
|
||||
browser = "Browser"
|
||||
date = "Date"
|
||||
device = "Device"
|
||||
ip = "IP"
|
||||
@@ -445,11 +465,23 @@ title = "Cancel consent"
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
last_auth_label = "Last sign-in"
|
||||
status_history = "Activity history"
|
||||
link_status_label = "Link status"
|
||||
status_history = "Link details"
|
||||
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = "Linked"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "Active"
|
||||
current_badge = "Current"
|
||||
current_disabled = "Current session"
|
||||
unknown_device = "Unknown device"
|
||||
unknown_session = "Session"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "End session"
|
||||
title = "End session"
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = "Default"
|
||||
userfront = "Approved UserFront session ID"
|
||||
@@ -459,7 +491,7 @@ confirm_button = "Disconnect"
|
||||
title = "Disconnect app"
|
||||
|
||||
[ui.userfront.dashboard.scopes]
|
||||
title = "Permission (Scopes)"
|
||||
title = "Consent scopes"
|
||||
|
||||
[ui.userfront.dashboard.status]
|
||||
revoked = "Revoked"
|
||||
@@ -584,6 +616,7 @@ title = "Create a new password"
|
||||
[ui.userfront.sections]
|
||||
apps = "Apps"
|
||||
audit = "Audit"
|
||||
sessions = "Sessions"
|
||||
|
||||
[ui.userfront.session]
|
||||
active = "Active session"
|
||||
@@ -631,3 +664,11 @@ verify = "Verification"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Go to sign-in"
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "Manage My Activity"
|
||||
toggle_label = "Show active sessions only"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "Toggle to view only active sessions."
|
||||
|
||||
@@ -40,13 +40,223 @@ verify_code_failed = "인증 실패: {error}"
|
||||
[err.userfront.session]
|
||||
missing = "활성 세션이 없습니다."
|
||||
|
||||
[msg.userfront.audit]
|
||||
browser = "브라우저: {value}"
|
||||
date = "접속일자: {value}"
|
||||
device = "접속환경: {value}"
|
||||
end = "더 이상 항목이 없습니다."
|
||||
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||
ip = "접속 IP: {value}"
|
||||
load_more_error = "더 불러오지 못했습니다."
|
||||
result = "인증결과: {value}"
|
||||
session_id = "Session ID: {value}"
|
||||
status = "현황: (준비중)"
|
||||
|
||||
[msg.userfront.dashboard]
|
||||
approved_device = "승인 기기: {device}"
|
||||
approved_ip = "승인 IP: {ip}"
|
||||
audit_empty = "최근 접속 이력이 없습니다."
|
||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||
auth_method = "인증수단: {method}"
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "Client ID 없음"
|
||||
current_status = "현재 상태: {status}"
|
||||
last_auth = "최근 인증: {value}"
|
||||
link_status = "연동 상태: {status}"
|
||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||
link_open_error = "해당 링크를 열 수 없습니다."
|
||||
render_error = "대시보드 렌더링 오류: {error}"
|
||||
session_id_copied = "세션 ID가 복사되었습니다."
|
||||
|
||||
[msg.userfront.error]
|
||||
detail_contact = "관리자에게 문의해 주세요."
|
||||
detail_generic = "오류가 발생했습니다."
|
||||
detail_request = "요청을 처리하는 중 문제가 발생했습니다."
|
||||
id = "오류 ID: {id}"
|
||||
title = "인증 과정에서 오류가 발생했습니다"
|
||||
title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {code}"
|
||||
type = "오류 종류: {type}"
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
error = "전송에 실패했습니다: {error}"
|
||||
input_required = "이메일 또는 휴대폰 번호를 입력해주세요."
|
||||
sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."
|
||||
|
||||
[msg.userfront.login]
|
||||
cookie_check_failed = "로그인 확인 실패: {error}"
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
link_failed = "오류: {error}"
|
||||
link_send_failed = "전송 실패: {error}"
|
||||
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
|
||||
link_timeout = "시간이 경과되었습니다."
|
||||
no_account = "계정이 없으신가요?"
|
||||
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
|
||||
qr_expired = "시간이 경과되었습니다."
|
||||
qr_init_failed = "QR 초기화에 실패했습니다: {error}"
|
||||
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
|
||||
token_missing = "로그인 토큰을 확인할 수 없습니다."
|
||||
verification_failed = "승인 처리에 실패했습니다: {error}"
|
||||
|
||||
[msg.userfront.login_success]
|
||||
subtitle = "성공적으로 로그인되었습니다."
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = "동의 처리에 실패했습니다: {error}"
|
||||
client_id = "클라이언트 ID: {id}"
|
||||
client_unknown = "알 수 없는 앱"
|
||||
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
|
||||
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
|
||||
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
|
||||
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
|
||||
scope_count = "총 {count}개"
|
||||
|
||||
[msg.userfront.profile]
|
||||
department_missing = "소속 정보 없음"
|
||||
department_required = "소속을 입력해주세요."
|
||||
email_missing = "이메일 없음"
|
||||
greeting = "안녕하세요, {name}님"
|
||||
load_failed = "정보를 불러올 수 없습니다."
|
||||
name_missing = "이름 없음"
|
||||
name_required = "이름을 입력해주세요."
|
||||
phone_required = "휴대폰 번호를 입력해주세요."
|
||||
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
|
||||
update_failed = "수정 실패: {error}"
|
||||
update_success = "정보가 수정되었습니다."
|
||||
|
||||
[msg.userfront.qr]
|
||||
camera_error = "카메라 오류: {error}"
|
||||
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
|
||||
permission_required = "카메라 권한이 필요합니다."
|
||||
|
||||
[msg.userfront.reset]
|
||||
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
|
||||
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
|
||||
invalid_title = "유효하지 않은 링크입니다."
|
||||
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
|
||||
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
|
||||
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
|
||||
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
|
||||
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||
|
||||
[msg.userfront.signup]
|
||||
failed = "가입 실패: {error}"
|
||||
privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
||||
tos_full = "서비스 이용약관 전문..."
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
|
||||
[ui.common.status]
|
||||
active = "활성"
|
||||
blocked = "차단됨"
|
||||
failure = "실패"
|
||||
inactive = "비활성"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
success = "성공"
|
||||
|
||||
[ui.userfront.app_label]
|
||||
admin_console = "Admin Console"
|
||||
baron = "Baron 로그인"
|
||||
dev_console = "Dev Console"
|
||||
|
||||
[ui.userfront.auth_method]
|
||||
ory = "Ory 세션"
|
||||
session = "세션"
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
last_auth_label = "최근 인증"
|
||||
link_status_label = "연동 상태"
|
||||
status_history = "연동 정보"
|
||||
|
||||
[ui.userfront.device]
|
||||
android = "Mobile(Android)"
|
||||
ios = "Mobile(iOS)"
|
||||
linux = "Desktop(Linux)"
|
||||
macos = "Desktop(macOS)"
|
||||
windows = "Desktop(Windows)"
|
||||
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
input_label = "이메일 또는 휴대폰 번호"
|
||||
submit = "재설정 링크 전송"
|
||||
title = "비밀번호 재설정"
|
||||
|
||||
[ui.userfront.login]
|
||||
forgot_password = "비밀번호를 잊으셨나요?"
|
||||
signup = "회원가입"
|
||||
|
||||
[ui.userfront.login_success]
|
||||
later = "나중에 하기 (대시보드로 이동)"
|
||||
qr = "QR 인증 (카메라 켜기)"
|
||||
title = "로그인 완료"
|
||||
|
||||
[ui.userfront.consent]
|
||||
accept = "동의하고 계속하기"
|
||||
requested_scopes = "요청된 권한"
|
||||
title = "접근 권한 요청"
|
||||
|
||||
[ui.userfront.nav]
|
||||
dashboard = "대시보드"
|
||||
logout = "로그아웃"
|
||||
profile = "내 정보"
|
||||
qr_scan = "QR 스캔"
|
||||
|
||||
[ui.userfront.profile]
|
||||
department_empty = "소속 정보 없음"
|
||||
manage = "프로필 관리"
|
||||
user_fallback = "사용자"
|
||||
|
||||
[ui.userfront.qr]
|
||||
rescan = "다시 스캔"
|
||||
result_success = "승인 완료"
|
||||
title = "Scan QR Code"
|
||||
|
||||
[ui.userfront.reset]
|
||||
confirm_password = "새 비밀번호 확인"
|
||||
new_password = "새 비밀번호"
|
||||
submit = "비밀번호 변경"
|
||||
subtitle = "새로운 비밀번호 설정"
|
||||
title = "새 비밀번호 설정"
|
||||
|
||||
[ui.userfront.sections]
|
||||
apps = "나의 App 현황"
|
||||
audit = "접속이력"
|
||||
sessions = "활성 세션"
|
||||
|
||||
[ui.userfront.session]
|
||||
active = "세션 활성"
|
||||
unknown = "알 수 없음"
|
||||
|
||||
[ui.userfront.signup]
|
||||
complete = "가입 완료"
|
||||
next_step = "다음 단계"
|
||||
title = "회원가입"
|
||||
|
||||
[msg.userfront]
|
||||
greeting = "안녕하세요, {name}님"
|
||||
|
||||
[msg.userfront.audit]
|
||||
browser = "브라우저: {value}"
|
||||
date = "접속일자: {value}"
|
||||
device = "접속환경: {value}"
|
||||
end = "더 이상 항목이 없습니다."
|
||||
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||
ip = "접속 IP: {value}"
|
||||
load_more_error = "더 불러오지 못했습니다."
|
||||
result = "인증결과: {value}"
|
||||
@@ -94,6 +304,20 @@ empty = "연동된 앱이 없습니다."
|
||||
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||
error = "연동 정보를 불러오지 못했습니다."
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = "브라우저: {value}"
|
||||
empty = "활성 세션이 없습니다."
|
||||
empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다."
|
||||
error = "세션 정보를 불러오지 못했습니다."
|
||||
os = "OS: {value}"
|
||||
recent_app = "최근 접속 앱: {app}"
|
||||
session_id = "세션 ID: {id}"
|
||||
|
||||
[msg.userfront.dashboard.sessions.revoke]
|
||||
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
|
||||
error = "세션 종료 실패: {error}"
|
||||
success = "세션이 종료되었습니다."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
|
||||
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
|
||||
@@ -420,8 +644,10 @@ dev_console = "Dev Console"
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = "관리"
|
||||
app = "애플리케이션"
|
||||
auth_method = "인증수단"
|
||||
browser = "브라우저"
|
||||
date = "접속일자"
|
||||
device = "접속환경"
|
||||
ip = "IP"
|
||||
@@ -450,6 +676,17 @@ status_history = "상태 이력"
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = "연동됨"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "활성화"
|
||||
current_badge = "접속중"
|
||||
current_disabled = "현재 세션"
|
||||
unknown_device = "알 수 없는 기기"
|
||||
unknown_session = "세션 정보"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "세션 종료"
|
||||
title = "세션 종료"
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = "승인한 세션 ID"
|
||||
userfront = "승인한 Userfront 세션 ID"
|
||||
@@ -459,7 +696,7 @@ confirm_button = "해지하기"
|
||||
title = "연동 해지"
|
||||
|
||||
[ui.userfront.dashboard.scopes]
|
||||
title = "권한 (Scopes)"
|
||||
title = "동의 범위"
|
||||
|
||||
[ui.userfront.dashboard.status]
|
||||
revoked = "해지됨"
|
||||
@@ -631,3 +868,11 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "내 활동 관리"
|
||||
toggle_label = "활성 세션만 보기"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
@@ -40,13 +40,195 @@ verify_code_failed = ""
|
||||
[err.userfront.session]
|
||||
missing = ""
|
||||
|
||||
[msg.userfront.error]
|
||||
detail_contact = ""
|
||||
detail_generic = ""
|
||||
detail_request = ""
|
||||
id = ""
|
||||
title = ""
|
||||
title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
dry_send = ""
|
||||
error = ""
|
||||
input_required = ""
|
||||
sent = ""
|
||||
|
||||
[msg.userfront.login]
|
||||
cookie_check_failed = ""
|
||||
dry_send = ""
|
||||
link_failed = ""
|
||||
link_send_failed = ""
|
||||
link_sent_email = ""
|
||||
link_sent_phone = ""
|
||||
link_timeout = ""
|
||||
no_account = ""
|
||||
oidc_failed = ""
|
||||
qr_expired = ""
|
||||
qr_init_failed = ""
|
||||
qr_login_required = ""
|
||||
token_missing = ""
|
||||
verification_failed = ""
|
||||
|
||||
[msg.userfront.login_success]
|
||||
subtitle = ""
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = ""
|
||||
client_id = ""
|
||||
client_unknown = ""
|
||||
description = ""
|
||||
load_error = ""
|
||||
missing_redirect = ""
|
||||
redirect_notice = ""
|
||||
scope_count = ""
|
||||
|
||||
[msg.userfront.profile]
|
||||
department_missing = ""
|
||||
department_required = ""
|
||||
email_missing = ""
|
||||
greeting = ""
|
||||
load_failed = ""
|
||||
name_missing = ""
|
||||
name_required = ""
|
||||
phone_required = ""
|
||||
phone_verify_required = ""
|
||||
update_failed = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.userfront.qr]
|
||||
camera_error = ""
|
||||
permission_error = ""
|
||||
permission_required = ""
|
||||
|
||||
[msg.userfront.reset]
|
||||
invalid_body = ""
|
||||
invalid_link = ""
|
||||
invalid_title = ""
|
||||
policy_loading = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = ""
|
||||
audit_subtitle = ""
|
||||
sessions_subtitle = ""
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = ""
|
||||
|
||||
[msg.userfront.signup]
|
||||
failed = ""
|
||||
privacy_full = ""
|
||||
tos_full = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
system = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
blocked = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
success = ""
|
||||
|
||||
[ui.userfront.app_label]
|
||||
admin_console = ""
|
||||
baron = ""
|
||||
dev_console = ""
|
||||
|
||||
[ui.userfront.auth_method]
|
||||
ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
[ui.userfront.device]
|
||||
android = ""
|
||||
ios = ""
|
||||
linux = ""
|
||||
macos = ""
|
||||
windows = ""
|
||||
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
input_label = ""
|
||||
submit = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.login]
|
||||
forgot_password = ""
|
||||
signup = ""
|
||||
|
||||
[ui.userfront.login_success]
|
||||
later = ""
|
||||
qr = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.consent]
|
||||
accept = ""
|
||||
requested_scopes = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.nav]
|
||||
dashboard = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
qr_scan = ""
|
||||
|
||||
[ui.userfront.profile]
|
||||
department_empty = ""
|
||||
manage = ""
|
||||
user_fallback = ""
|
||||
|
||||
[ui.userfront.qr]
|
||||
rescan = ""
|
||||
result_success = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.reset]
|
||||
confirm_password = ""
|
||||
new_password = ""
|
||||
submit = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.sections]
|
||||
apps = ""
|
||||
audit = ""
|
||||
sessions = ""
|
||||
|
||||
[ui.userfront.session]
|
||||
active = ""
|
||||
unknown = ""
|
||||
|
||||
[ui.userfront.signup]
|
||||
complete = ""
|
||||
next_step = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront]
|
||||
greeting = ""
|
||||
|
||||
[msg.userfront.audit]
|
||||
browser = ""
|
||||
date = ""
|
||||
device = ""
|
||||
end = ""
|
||||
filtered_empty = ""
|
||||
ip = ""
|
||||
load_more_error = ""
|
||||
result = ""
|
||||
@@ -94,6 +276,20 @@ empty = ""
|
||||
empty_detail = ""
|
||||
error = ""
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = ""
|
||||
empty = ""
|
||||
empty_detail = ""
|
||||
error = ""
|
||||
os = ""
|
||||
recent_app = ""
|
||||
session_id = ""
|
||||
|
||||
[msg.userfront.dashboard.sessions.revoke]
|
||||
confirm = ""
|
||||
error = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = ""
|
||||
copy_tap = ""
|
||||
@@ -420,8 +616,10 @@ dev_console = ""
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = ""
|
||||
app = ""
|
||||
auth_method = ""
|
||||
browser = ""
|
||||
date = ""
|
||||
device = ""
|
||||
ip = ""
|
||||
@@ -450,6 +648,17 @@ status_history = ""
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = ""
|
||||
current_badge = ""
|
||||
current_disabled = ""
|
||||
unknown_device = ""
|
||||
unknown_session = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = ""
|
||||
userfront = ""
|
||||
@@ -631,3 +840,11 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = ""
|
||||
toggle_label = ""
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = ""
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:toml/toml.dart';
|
||||
|
||||
import '../../i18n_data.dart';
|
||||
|
||||
class TomlAssetLoader extends AssetLoader {
|
||||
const TomlAssetLoader();
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> load(String path, Locale locale) async {
|
||||
final assetPath = '$path/${locale.languageCode}.toml';
|
||||
try {
|
||||
final content = await rootBundle.loadString(assetPath);
|
||||
final document = TomlDocument.parse(content);
|
||||
return document.toMap();
|
||||
} catch (e) {
|
||||
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
|
||||
return {};
|
||||
}
|
||||
final languageCode = locale.languageCode.toLowerCase();
|
||||
final source = switch (languageCode) {
|
||||
'ko' => koStrings,
|
||||
'en' => enStrings,
|
||||
_ => enStrings,
|
||||
};
|
||||
return _expandFlatTranslations(source);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _expandFlatTranslations(Map<String, String> flatMap) {
|
||||
final nested = <String, dynamic>{};
|
||||
for (final entry in flatMap.entries) {
|
||||
final key = entry.key;
|
||||
if (key.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final segments = key.split('.');
|
||||
Map<String, dynamic> cursor = nested;
|
||||
for (var index = 0; index < segments.length; index++) {
|
||||
final segment = segments[index];
|
||||
if (segment.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final isLeaf = index == segments.length - 1;
|
||||
if (isLeaf) {
|
||||
cursor[segment] = _normalizeLocalizationValue(entry.value);
|
||||
continue;
|
||||
}
|
||||
final next = cursor.putIfAbsent(segment, () => <String, dynamic>{});
|
||||
if (next is Map<String, dynamic>) {
|
||||
cursor = next;
|
||||
continue;
|
||||
}
|
||||
final replacement = <String, dynamic>{};
|
||||
cursor[segment] = replacement;
|
||||
cursor = replacement;
|
||||
}
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
String _normalizeLocalizationValue(String value) {
|
||||
return value.replaceAllMapped(
|
||||
RegExp(r'\{\{[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*\}\}'),
|
||||
(match) => '{${match.group(1)}}',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,6 +241,64 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> revokeSession(String sessionId) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
try {
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.delete(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw _error(
|
||||
'err.userfront.dashboard.sessions.revoke',
|
||||
'세션 종료에 실패했습니다: {{error}}',
|
||||
detail: response.body,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> fetchCurrentSessionId() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/sessions');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
try {
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw _error(
|
||||
'err.userfront.dashboard.sessions.load',
|
||||
'활성 세션을 불러오지 못했습니다: {{error}}',
|
||||
detail: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
for (final item in items.whereType<Map<String, dynamic>>()) {
|
||||
if (item['is_current'] == true) {
|
||||
final sessionId = item['session_id']?.toString().trim() ?? '';
|
||||
if (sessionId.isNotEmpty) {
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyLoginShortCode(
|
||||
String shortCode, {
|
||||
bool verifyOnly = false,
|
||||
|
||||
39
userfront/lib/core/services/logout_service.dart
Normal file
39
userfront/lib/core/services/logout_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import '../notifiers/auth_notifier.dart';
|
||||
import 'auth_proxy_service.dart';
|
||||
import 'auth_token_store.dart';
|
||||
|
||||
typedef CurrentSessionLoader = Future<String?> Function();
|
||||
typedef SessionRevoker = Future<void> Function(String sessionId);
|
||||
typedef LogoutCallback = void Function();
|
||||
|
||||
class LogoutService {
|
||||
LogoutService({
|
||||
CurrentSessionLoader? loadCurrentSessionId,
|
||||
SessionRevoker? revokeSession,
|
||||
LogoutCallback? clearAuth,
|
||||
LogoutCallback? notifyAuthChanged,
|
||||
}) : _loadCurrentSessionId =
|
||||
loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId,
|
||||
_revokeSession = revokeSession ?? AuthProxyService.revokeSession,
|
||||
_clearAuth = clearAuth ?? AuthTokenStore.clear,
|
||||
_notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify;
|
||||
|
||||
final CurrentSessionLoader _loadCurrentSessionId;
|
||||
final SessionRevoker _revokeSession;
|
||||
final LogoutCallback _clearAuth;
|
||||
final LogoutCallback _notifyAuthChanged;
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
final currentSessionId = await _loadCurrentSessionId();
|
||||
if (currentSessionId != null && currentSessionId.isNotEmpty) {
|
||||
await _revokeSession(currentSessionId);
|
||||
}
|
||||
} catch (_) {
|
||||
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
|
||||
} finally {
|
||||
_clearAuth();
|
||||
_notifyAuthChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
148
userfront/lib/core/theme/app_theme.dart
Normal file
148
userfront/lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildLightTheme() {
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1A1F2C),
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
surface: Colors.white,
|
||||
surfaceContainerLowest: const Color(0xFFF7F8FA),
|
||||
surfaceContainerLow: const Color(0xFFF3F4F6),
|
||||
surfaceContainerHighest: const Color(0xFFE5E7EB),
|
||||
outline: const Color(0xFFD1D5DB),
|
||||
outlineVariant: const Color(0xFFE5E7EB),
|
||||
primary: const Color(0xFF1A1F2C),
|
||||
onPrimary: Colors.white,
|
||||
onSurface: const Color(0xFF111827),
|
||||
onSurfaceVariant: const Color(0xFF6B7280),
|
||||
);
|
||||
return _buildTheme(scheme);
|
||||
}
|
||||
|
||||
ThemeData buildDarkTheme() {
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF7DD3FC),
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(
|
||||
surface: const Color(0xFF0F172A),
|
||||
surfaceContainerLowest: const Color(0xFF020617),
|
||||
surfaceContainerLow: const Color(0xFF111827),
|
||||
surfaceContainerHighest: const Color(0xFF1F2937),
|
||||
outline: const Color(0xFF334155),
|
||||
outlineVariant: const Color(0xFF1E293B),
|
||||
primary: const Color(0xFFBAE6FD),
|
||||
onPrimary: const Color(0xFF082F49),
|
||||
onSurface: const Color(0xFFF8FAFC),
|
||||
onSurfaceVariant: const Color(0xFF94A3B8),
|
||||
);
|
||||
return _buildTheme(scheme);
|
||||
}
|
||||
|
||||
ThemeData _buildTheme(ColorScheme colorScheme) {
|
||||
final isDark = colorScheme.brightness == Brightness.dark;
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: 'NotoSansKR',
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: colorScheme.surfaceContainerLowest,
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: NoTransitionsBuilder(),
|
||||
TargetPlatform.iOS: NoTransitionsBuilder(),
|
||||
TargetPlatform.linux: NoTransitionsBuilder(),
|
||||
TargetPlatform.macOS: NoTransitionsBuilder(),
|
||||
TargetPlatform.windows: NoTransitionsBuilder(),
|
||||
TargetPlatform.fuchsia: NoTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
backgroundColor: colorScheme.surface,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colorScheme.surface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colorScheme.outlineVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
drawerTheme: DrawerThemeData(
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 1.4),
|
||||
),
|
||||
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
prefixIconColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
tabBarTheme: TabBarThemeData(
|
||||
dividerColor: colorScheme.outlineVariant,
|
||||
labelColor: colorScheme.onSurface,
|
||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||
indicatorColor: colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class NoTransitionsBuilder extends PageTransitionsBuilder {
|
||||
const NoTransitionsBuilder();
|
||||
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
PageRoute<T> route,
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
37
userfront/lib/core/theme/theme_controller.dart
Normal file
37
userfront/lib/core/theme/theme_controller.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ThemeController extends ValueNotifier<ThemeMode> {
|
||||
ThemeController._(this.storageKey) : super(ThemeMode.light);
|
||||
|
||||
static const appStorageKey = 'userfront_theme';
|
||||
static const authStorageKey = 'userfront_auth_theme';
|
||||
static final ThemeController app = ThemeController._(appStorageKey);
|
||||
static final ThemeController auth = ThemeController._(authStorageKey);
|
||||
static final ThemeController instance = app;
|
||||
|
||||
final String storageKey;
|
||||
|
||||
bool get isDark => value == ThemeMode.dark;
|
||||
|
||||
Future<void> restore() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString(storageKey);
|
||||
value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
if (value != mode) {
|
||||
value = mode;
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
storageKey,
|
||||
mode == ThemeMode.dark ? 'dark' : 'light',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggle() {
|
||||
return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/theme/theme_scope.dart
Normal file
44
userfront/lib/core/theme/theme_scope.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_theme.dart';
|
||||
import 'theme_controller.dart';
|
||||
|
||||
class ThemeScope extends InheritedWidget {
|
||||
const ThemeScope({super.key, required this.controller, required Widget child})
|
||||
: super(child: child);
|
||||
|
||||
final ThemeController controller;
|
||||
|
||||
static ThemeController of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<ThemeScope>();
|
||||
return scope?.controller ?? ThemeController.app;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ThemeScope oldWidget) {
|
||||
return oldWidget.controller != controller;
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedTheme extends StatelessWidget {
|
||||
const ScopedTheme({super.key, required this.controller, required this.child});
|
||||
|
||||
final ThemeController controller;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ThemeScope(
|
||||
controller: controller,
|
||||
child: ValueListenableBuilder<ThemeMode>(
|
||||
valueListenable: controller,
|
||||
builder: (context, mode, _) {
|
||||
return Theme(
|
||||
data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal file
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import '../theme/theme_scope.dart';
|
||||
|
||||
class ThemeToggleButton extends StatelessWidget {
|
||||
const ThemeToggleButton({super.key, this.compact = false});
|
||||
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Localizations.localeOf(context);
|
||||
final controller = ThemeScope.of(context);
|
||||
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
valueListenable: controller,
|
||||
builder: (context, mode, _) {
|
||||
final isLight = mode == ThemeMode.light;
|
||||
final icon = isLight
|
||||
? Icons.light_mode_outlined
|
||||
: Icons.dark_mode_outlined;
|
||||
final label = isLight
|
||||
? tr('ui.common.theme_light', fallback: 'Light')
|
||||
: tr('ui.common.theme_dark', fallback: 'Dark');
|
||||
final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환');
|
||||
|
||||
if (compact) {
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: () => controller.toggle(),
|
||||
icon: Icon(icon),
|
||||
);
|
||||
}
|
||||
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => controller.toggle(),
|
||||
icon: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,40 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
};
|
||||
}
|
||||
|
||||
String _renderConsentText(String key, {String? fallback}) {
|
||||
return tr(
|
||||
key,
|
||||
fallback: fallback,
|
||||
).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n');
|
||||
}
|
||||
|
||||
String _renderScopeCountLabel(int count) {
|
||||
return tr(
|
||||
'msg.userfront.consent.scope_count',
|
||||
fallback: 'Total {{count}}',
|
||||
params: {'count': '$count'},
|
||||
).replaceAll('{$count}', '$count');
|
||||
}
|
||||
|
||||
String _scopeDisplayLabel(String scope) {
|
||||
if (scope == 'offline_access') {
|
||||
return 'offline access';
|
||||
}
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
String _renderClientIdLabel(String clientId) {
|
||||
final raw = tr(
|
||||
'msg.userfront.consent.client_id',
|
||||
fallback: 'Client ID: {{id}}',
|
||||
);
|
||||
final normalized = raw
|
||||
.replaceAll('{{id}}', '')
|
||||
.replaceAll('{id}', '')
|
||||
.trimRight();
|
||||
return '$normalized $clientId';
|
||||
}
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final info = await AuthProxyService.getConsentInfo(
|
||||
@@ -271,7 +305,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr('msg.userfront.consent.description'),
|
||||
_renderConsentText('msg.userfront.consent.description'),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -318,11 +352,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.consent.client_id',
|
||||
fallback: 'Client ID: {{id}}',
|
||||
params: {'id': clientId},
|
||||
),
|
||||
_renderClientIdLabel(clientId),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
@@ -349,11 +379,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.consent.scope_count',
|
||||
fallback: 'Total {{count}}',
|
||||
params: {'count': '${requestedScopes.length}'},
|
||||
),
|
||||
_renderScopeCountLabel(requestedScopes.length),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
@@ -371,7 +397,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
scope, // 스코프 키 (예: openid)
|
||||
_scopeDisplayLabel(scope),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(description),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/error_whitelist.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/widgets/theme_toggle_button.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ErrorScreen extends StatelessWidget {
|
||||
@@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
@@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget {
|
||||
: tr('msg.userfront.error.detail_request')));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F8FA),
|
||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
@@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget {
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
@@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF4B5563),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
@@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget {
|
||||
Text(
|
||||
tr('msg.userfront.error.type', params: {'type': errorType}),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF6B7280),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (errorId != null && errorId!.isNotEmpty) ...[
|
||||
@@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget {
|
||||
Text(
|
||||
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF6B7280),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget {
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF111827),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
@@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget {
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedHomePath(Uri.base)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF111827),
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,18 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -123,16 +135,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
values: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
values: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,18 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
Timer? _phoneTimer;
|
||||
int _phoneSeconds = 0;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -1663,16 +1675,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.signup.policy.min_length',
|
||||
params: {'count': minLength.toString()},
|
||||
values: {'count': minLength.toString()},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.signup.policy.min_types',
|
||||
params: {'count': minTypes.toString()},
|
||||
values: {'count': minTypes.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1689,9 +1701,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||
}
|
||||
|
||||
return tr(
|
||||
return _renderTranslatedText(
|
||||
'msg.userfront.signup.policy.summary',
|
||||
params: {'rules': parts.join(', ')},
|
||||
values: {'rules': parts.join(', ')},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'providers/linked_rps_provider.dart';
|
||||
|
||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||
final normalizedStatus = rp.status.trim().toLowerCase();
|
||||
final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active';
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final initUrl = rp.initUrl.trim();
|
||||
if (initUrl.isNotEmpty) {
|
||||
return initUrl;
|
||||
}
|
||||
|
||||
final url = rp.url.trim();
|
||||
if (url.isNotEmpty) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -96,6 +96,7 @@ class LinkedRp {
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
@@ -105,6 +106,7 @@ class LinkedRp {
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
this.lastAuthenticatedAt,
|
||||
@@ -126,6 +128,7 @@ class LinkedRp {
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
@@ -170,3 +173,59 @@ class RpHistoryItem {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSessionSummary {
|
||||
final String sessionId;
|
||||
final DateTime? authenticatedAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? issuedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final bool isCurrent;
|
||||
final bool isActive;
|
||||
|
||||
UserSessionSummary({
|
||||
required this.sessionId,
|
||||
this.authenticatedAt,
|
||||
this.expiresAt,
|
||||
this.issuedAt,
|
||||
this.lastSeenAt,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.isCurrent,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(dynamic raw) {
|
||||
final value = raw?.toString();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return UserSessionSummary(
|
||||
sessionId: json['session_id']?.toString() ?? '',
|
||||
authenticatedAt: parseDate(json['authenticated_at']),
|
||||
expiresAt: parseDate(json['expires_at']),
|
||||
issuedAt: parseDate(json['issued_at']),
|
||||
lastSeenAt: parseDate(json['last_seen_at']),
|
||||
ipAddress: json['ip_address']?.toString() ?? '',
|
||||
userAgent: json['user_agent']?.toString() ?? '',
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
appName: json['app_name']?.toString() ?? '',
|
||||
isCurrent: json['is_current'] == true,
|
||||
isActive: json['is_active'] != false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ class LinkedRp {
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
@@ -19,6 +20,7 @@ class LinkedRp {
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
@@ -40,6 +42,7 @@ class LinkedRp {
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
@override
|
||||
Future<List<UserSessionSummary>> build() async {
|
||||
return _fetchSessions();
|
||||
}
|
||||
|
||||
String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<List<UserSessionSummary>> _fetchSessions() async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load sessions: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserSessionSummary.fromJson)
|
||||
.toList();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(_fetchSessions);
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String sessionId) async {
|
||||
await AuthProxyService.revokeSession(sessionId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final userSessionsProvider =
|
||||
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
|
||||
return UserSessionsNotifier();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/logout_service.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../../../core/widgets/language_selector.dart';
|
||||
import '../../../../core/widgets/theme_toggle_button.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../domain/notifiers/profile_notifier.dart';
|
||||
|
||||
@@ -21,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
static const _ink = Color(0xFF1A1F2C);
|
||||
static const _surface = Colors.white;
|
||||
static const _border = Color(0xFFE5E7EB);
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
static final _log = Logger('ProfilePage');
|
||||
|
||||
UserProfile? _cachedProfile;
|
||||
@@ -55,9 +51,27 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _showCurrentPassword = false;
|
||||
bool _showNewPassword = false;
|
||||
bool _showConfirmPassword = false;
|
||||
bool _isDesktopSideMenuOpen = true;
|
||||
Map<String, dynamic>? _passwordPolicy;
|
||||
bool _isPasswordPolicyLoading = false;
|
||||
|
||||
Color get _ink => Theme.of(context).colorScheme.onSurface;
|
||||
Color get _surface => Theme.of(context).colorScheme.surface;
|
||||
Color get _border => Theme.of(context).colorScheme.outlineVariant;
|
||||
Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -99,16 +113,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.signup.policy.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
values: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
tr(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.signup.policy.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
values: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -125,9 +139,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||
}
|
||||
|
||||
return tr(
|
||||
return _renderTranslatedText(
|
||||
'msg.userfront.signup.policy.summary',
|
||||
params: {'rules': parts.join(", ")},
|
||||
values: {'rules': parts.join(", ")},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,8 +178,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
AuthTokenStore.clear();
|
||||
AuthNotifier.instance.notify();
|
||||
await LogoutService().logout();
|
||||
}
|
||||
|
||||
void _ensureControllers(UserProfile profile) {
|
||||
@@ -605,7 +618,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: LanguageSelector(compact: true),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ThemeToggleButton(),
|
||||
SizedBox(height: 8),
|
||||
LanguageSelector(compact: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -617,7 +637,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _ink,
|
||||
@@ -644,7 +664,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _ink,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -690,8 +710,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr('msg.userfront.profile.greeting', params: {'name': name}),
|
||||
style: const TextStyle(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.profile.greeting',
|
||||
fallback: 'Hello, {{name}}.',
|
||||
values: {'name': name},
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _ink,
|
||||
@@ -982,12 +1006,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tr('msg.userfront.profile.password.subtitle'),
|
||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_buildPasswordPolicyDescription(),
|
||||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
@@ -1217,14 +1246,35 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
return Scaffold(
|
||||
backgroundColor: _subtle,
|
||||
appBar: AppBar(
|
||||
leading: isWide
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
|
||||
),
|
||||
tooltip: _isDesktopSideMenuOpen
|
||||
? tr('ui.common.collapse')
|
||||
: '펼치기',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
|
||||
});
|
||||
},
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).openAppDrawerTooltip,
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
tr('ui.userfront.app_title'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: _surface,
|
||||
foregroundColor: Colors.black,
|
||||
actions: [
|
||||
const ThemeToggleButton(compact: true),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: tr('ui.userfront.nav.dashboard'),
|
||||
@@ -1245,7 +1295,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||
body: Row(
|
||||
children: [
|
||||
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||
if (isWide && _isDesktopSideMenuOpen)
|
||||
SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||
Expanded(child: _buildContent(profile, isUpdating)),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,6 +24,9 @@ import 'core/services/logger_service.dart';
|
||||
import 'core/services/null_check_recovery.dart';
|
||||
import 'core/services/web_window.dart';
|
||||
import 'core/notifiers/auth_notifier.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'core/theme/theme_scope.dart';
|
||||
import 'core/i18n/locale_gate.dart';
|
||||
import 'core/i18n/locale_registry.dart';
|
||||
import 'core/i18n/locale_utils.dart';
|
||||
@@ -106,6 +109,8 @@ void main() async {
|
||||
|
||||
// 0. Initialize Logger
|
||||
LoggerService.init();
|
||||
await ThemeController.app.restore();
|
||||
await ThemeController.auth.restore();
|
||||
|
||||
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
||||
await _loadBundledFonts();
|
||||
@@ -177,12 +182,18 @@ final _router = GoRouter(
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
builder: (context, state) {
|
||||
return const DashboardScreen();
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const DashboardScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const ProfilePage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signin',
|
||||
@@ -192,10 +203,13 @@ final _router = GoRouter(
|
||||
final redirectUrl =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'];
|
||||
return LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -208,10 +222,13 @@ final _router = GoRouter(
|
||||
final redirectUrl =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'];
|
||||
return LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -227,88 +244,137 @@ final _router = GoRouter(
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConsentScreen(consentChallenge: consentChallenge);
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ConsentScreen(consentChallenge: consentChallenge),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signup',
|
||||
builder: (context, state) => const SignupScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const SignupScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'registration',
|
||||
builder: (context, state) => const SignupScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const SignupScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verify',
|
||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verify/:token',
|
||||
builder: (context, state) {
|
||||
final token = state.pathParameters['token'];
|
||||
return LoginScreen(
|
||||
key: state.pageKey,
|
||||
verificationToken: token,
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
verificationToken: token,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verification',
|
||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'l/:shortCode',
|
||||
builder: (context, state) {
|
||||
return LoginScreen(key: state.pageKey);
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'forgot-password',
|
||||
builder: (context, state) => const ForgotPasswordScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ForgotPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'recovery',
|
||||
builder: (context, state) => const ForgotPasswordScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ForgotPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reset-password',
|
||||
builder: (context, state) => const ResetPasswordScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ResetPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'error',
|
||||
builder: (context, state) {
|
||||
final params = state.uri.queryParameters;
|
||||
return ErrorScreen(
|
||||
errorId: params['id'],
|
||||
errorCode: params['error'],
|
||||
description: params['error_description'] ?? params['message'],
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ErrorScreen(
|
||||
errorId: params['id'],
|
||||
errorCode: params['error'],
|
||||
description:
|
||||
params['error_description'] ?? params['message'],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
builder: (context, state) => ErrorScreen(
|
||||
errorCode: 'settings_disabled',
|
||||
description: tr('msg.userfront.settings.disabled'),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ErrorScreen(
|
||||
errorCode: 'settings_disabled',
|
||||
description: tr('msg.userfront.settings.disabled'),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'approve',
|
||||
builder: (context, state) =>
|
||||
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(
|
||||
pendingRef: state.uri.queryParameters['ref'],
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ql/:ref',
|
||||
builder: (context, state) =>
|
||||
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'scan',
|
||||
builder: (context, state) => const QRScanScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const QRScanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'admin/users',
|
||||
builder: (context, state) => const UserManagementScreen(),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const UserManagementScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget {
|
||||
children: [if (child != null) child, const ToastViewport()],
|
||||
);
|
||||
},
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'NotoSansKR',
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: NoTransitionsBuilder(),
|
||||
TargetPlatform.iOS: NoTransitionsBuilder(),
|
||||
TargetPlatform.linux: NoTransitionsBuilder(),
|
||||
TargetPlatform.macOS: NoTransitionsBuilder(),
|
||||
TargetPlatform.windows: NoTransitionsBuilder(),
|
||||
TargetPlatform.fuchsia: NoTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
),
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
themeMode: ThemeMode.light,
|
||||
routerConfig: _router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NoTransitionsBuilder extends PageTransitionsBuilder {
|
||||
const NoTransitionsBuilder();
|
||||
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
PageRoute<T> route,
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -388,6 +396,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -485,7 +501,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
@@ -753,6 +769,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.21"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -825,6 +865,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -40,6 +40,7 @@ dependencies:
|
||||
go_router: ^17.0.1
|
||||
http: ^1.6.0
|
||||
flutter_dotenv: ^6.0.0
|
||||
flutter_svg: ^2.2.1
|
||||
url_launcher: ^6.3.2
|
||||
logging: ^1.2.0
|
||||
logger: ^2.0.0
|
||||
@@ -48,6 +49,7 @@ dependencies:
|
||||
easy_localization: ^3.0.7
|
||||
toml: ^0.15.0
|
||||
web: ^1.1.0
|
||||
shared_preferences: ^2.5.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
72
userfront/test/linked_rp_launch_test.dart
Normal file
72
userfront/test/linked_rp_launch_test.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
|
||||
import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
|
||||
|
||||
LinkedRp _linkedRp({
|
||||
required String status,
|
||||
String url = '',
|
||||
String initUrl = '',
|
||||
}) {
|
||||
return LinkedRp(
|
||||
id: 'client-1',
|
||||
name: 'Example App',
|
||||
logo: '',
|
||||
url: url,
|
||||
initUrl: initUrl,
|
||||
status: status,
|
||||
scopes: const ['openid', 'profile'],
|
||||
lastAuthenticatedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('LinkedRp.fromJson은 init_url을 읽는다', () {
|
||||
final rp = LinkedRp.fromJson({
|
||||
'id': 'client-1',
|
||||
'name': 'Example App',
|
||||
'status': 'active',
|
||||
'url': 'https://example.com',
|
||||
'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
rp.initUrl,
|
||||
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'active',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
launchUrl,
|
||||
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(status: 'active', url: 'https://example.com'),
|
||||
);
|
||||
|
||||
expect(launchUrl, 'https://example.com');
|
||||
});
|
||||
|
||||
test('비활성 앱은 진입 URL을 만들지 않는다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'inactive',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(launchUrl, isNull);
|
||||
});
|
||||
}
|
||||
74
userfront/test/logout_service_test.dart
Normal file
74
userfront/test/logout_service_test.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/logout_service.dart';
|
||||
|
||||
void main() {
|
||||
test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return 'current-sid';
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return null;
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return 'current-sid';
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
throw Exception('revoke failed');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
|
||||
});
|
||||
}
|
||||
32
userfront/test/theme_controller_test.dart
Normal file
32
userfront/test/theme_controller_test.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:userfront/core/theme/theme_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await ThemeController.app.setThemeMode(ThemeMode.light);
|
||||
});
|
||||
|
||||
test('저장된 dark 값을 복원한다', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
ThemeController.appStorageKey: 'dark',
|
||||
});
|
||||
|
||||
await ThemeController.app.restore();
|
||||
|
||||
expect(ThemeController.app.value, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('toggle 결과를 저장한다', () async {
|
||||
await ThemeController.app.restore();
|
||||
await ThemeController.app.toggle();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(ThemeController.app.value, ThemeMode.dark);
|
||||
expect(prefs.getString(ThemeController.appStorageKey), 'dark');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user