forked from baron/baron-sso
Merge branch 'dev' into fix/rebac-env-sync-issue
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user