1
0
forked from baron/baron-sso

Merge branch 'dev' into fix/rebac-env-sync-issue

This commit is contained in:
2026-04-10 13:52:07 +09:00
79 changed files with 9316 additions and 1606 deletions

View File

@@ -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."

View File

@@ -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 = "활성화된 세션만 보려면 토글을 켜주세요."

View File

@@ -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 = ""

View File

@@ -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)}}',
);
}

View File

@@ -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,

View 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();
}
}
}

View 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;
}
}

View 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);
}
}

View 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,
);
},
),
);
}
}

View 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),
);
},
);
}
}

View File

@@ -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),

View File

@@ -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

View File

@@ -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'},
),
);
}

View File

@@ -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(', ')},
);
}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -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,

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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:

View File

@@ -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:

View 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);
});
}

View 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']);
});
}

View 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');
});
}