1
0
forked from baron/baron-sso

Merge pull request 'feature/uf-enhance' (#429) from feature/uf-enhance into dev

Reviewed-on: baron/baron-sso#429
This commit is contained in:
2026-03-23 16:02:37 +09:00
30 changed files with 2038 additions and 883 deletions

View File

@@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# OIDC 클라이언트 callback (콤마 구분)
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정
ADMINFRONT_URL=http://localhost:5173
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정
VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback

View File

@@ -120,6 +120,8 @@ jobs:
# Frontend OIDC configs for Staging
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
ADMINFRONT_URL=http://172.16.10.176:5173
DEVFRONT_URL=http://172.16.10.176:5174
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}

View File

@@ -117,6 +117,10 @@ endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
FLUTTER_TEST_CONCURRENCY ?= 1
code-check: code-check-lint code-check-test-jobs
@echo "code-check complete."
@@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c
code-check-test-jobs:
@echo "==> run CI-equivalent test jobs (parallel)"
@$(MAKE) --no-print-directory -j5 --output-sync=target \
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \
code-check-userfront-tests \
code-check-userfront-e2e-tests \
@@ -203,11 +207,11 @@ code-check-userfront-tests:
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
fi; \
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
cd "$$tmp_dir/userfront" && flutter test
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests:
@echo "==> adminfront tests"
./scripts/run_adminfront_ci_tests.sh adminfront-tests
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
code-check-devfront-tests:
@echo "==> devfront tests"
@@ -219,7 +223,7 @@ code-check-devfront-tests:
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && npm test) || status=$$?; \
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
@@ -267,7 +271,7 @@ code-check-userfront-e2e-tests:
if [ $$status -eq 0 ]; then \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
echo "==> userfront-e2e using PORT=$$port"; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -24,7 +28,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = clientID
}
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
lastAuth := time.Time{}
if session.AuthenticatedAt != nil {
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = client.ClientID
}
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
@@ -5423,6 +5420,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
return ""
}
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
switch strings.TrimSpace(clientID) {
case "adminfront":
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
return value
}
case "devfront":
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
return value
}
}
clientURL := strings.TrimSpace(clientURI)
if clientURL != "" {
return clientURL
}
if len(redirectURIs) > 0 {
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
return ""
}
func mergeScopes(current []string, next []string) []string {
if len(next) == 0 {
return current

View File

@@ -211,6 +211,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
@@ -220,6 +221,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -20,7 +24,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -282,6 +282,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
@@ -291,6 +292,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \

File diff suppressed because one or more lines are too long

View File

@@ -676,6 +676,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth]
@@ -1709,6 +1714,7 @@ title = "회원가입"
[ui.userfront.signup.agreement]
all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth]
@@ -1742,4 +1748,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"

View File

@@ -676,6 +676,11 @@ privacy_full = ""
tos_full = ""
[msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = ""
[msg.userfront.signup.auth]
@@ -1709,6 +1714,7 @@ title = ""
[ui.userfront.signup.agreement]
all = ""
privacy_title = ""
required = ""
tos_title = ""
[ui.userfront.signup.auth]
@@ -1742,4 +1748,3 @@ verify = ""
[ui.userfront.signup.success]
action = ""

View File

@@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}"
ADMINFRONT_URL="${ADMINFRONT_URL:-http://172.16.10.176:5173}"
DEVFRONT_URL="${DEVFRONT_URL:-http://172.16.10.176:5174}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=()
@@ -382,12 +384,21 @@ run_validation() {
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
validate_dotenv_line_safety "KRATOS_UI_URL"
validate_dotenv_line_safety "ADMINFRONT_URL"
validate_dotenv_line_safety "DEVFRONT_URL"
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
if [[ -n "$ADMINFRONT_URL" ]]; then
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
fi
if [[ -n "$DEVFRONT_URL" ]]; then
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
fi
collect_values
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}"
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
validate_gateway_mapping
build_allowed_return_urls
}

View File

@@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI;
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
export default defineConfig({
testDir: './tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
use: {
baseURL,

View File

@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
await page.waitForTimeout(250);
}
async function submitDepartmentEditor(page: Page): Promise<void> {
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
}
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
await page.unroute('**/api/v1/**');
});
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
page,
}) => {
const state: ProfileState = {
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
expect(state.putBodies[0]?.department).toBe('QA-Updated');
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
expect(state.department).toBe('QA');
});
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
const state: ProfileState = {
department: 'QA',
getMeCount: 0,
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
await page.reload();
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(2);
expect(state.putBodies[0]?.department).toBe('QA-1');

File diff suppressed because one or more lines are too long

View File

@@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = "회원가입"
[ui.userfront.signup.agreement]
all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"

View File

@@ -277,6 +277,11 @@ privacy_full = ""
tos_full = ""
[msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = ""
[msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = ""
[ui.userfront.signup.agreement]
all = ""
privacy_title = ""
required = ""
tos_title = ""
[ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = ""
[ui.userfront.signup.success]
action = ""

View File

@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
enum ToastType { success, error, info }
class _ToastItem {
const _ToastItem({
required this.id,
required this.message,
required this.type,
});
final String id;
final String message;
final ToastType type;
}
class ToastService {
static const Duration _displayDuration = Duration(milliseconds: 3000);
static final ValueNotifier<List<_ToastItem>> _toasts =
ValueNotifier<List<_ToastItem>>(<_ToastItem>[]);
static void success(String message) {
show(message, type: ToastType.success);
}
static void error(String message) {
show(message, type: ToastType.error);
}
static void info(String message) {
show(message, type: ToastType.info);
}
static void show(String message, {ToastType type = ToastType.success}) {
final trimmed = message.trim();
if (trimmed.isEmpty) {
return;
}
final item = _ToastItem(
id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}',
message: trimmed,
type: type,
);
_toasts.value = [..._toasts.value, item];
unawaited(
Future<void>.delayed(_displayDuration, () {
_remove(item.id);
}),
);
}
static void _remove(String id) {
final next = _toasts.value.where((toast) => toast.id != id).toList();
if (next.length == _toasts.value.length) {
return;
}
_toasts.value = next;
}
}
class ToastViewport extends StatelessWidget {
const ToastViewport({super.key});
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: true,
child: SafeArea(
child: ValueListenableBuilder<List<_ToastItem>>(
valueListenable: ToastService._toasts,
builder: (context, toasts, _) {
if (toasts.isEmpty) {
return const SizedBox.shrink();
}
final media = MediaQuery.of(context);
final width = math.min(320.0, media.size.width - 32);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 16, bottom: 16),
child: SizedBox(
width: width > 0 ? width : 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final toast in toasts)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _ToastCard(item: toast),
),
],
),
),
),
);
},
),
),
);
}
}
class _ToastCard extends StatefulWidget {
const _ToastCard({required this.item});
final _ToastItem item;
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> {
bool _visible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
setState(() {
_visible = true;
});
});
}
@override
Widget build(BuildContext context) {
final scheme = _toastColorScheme(widget.item.type);
final icon = _toastIcon(widget.item.type);
return AnimatedSlide(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
offset: _visible ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _visible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.border),
boxShadow: const [
BoxShadow(
color: Color(0x26000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: scheme.foreground),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.item.message,
style: TextStyle(
color: scheme.foreground,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
decoration: TextDecoration.none,
),
),
),
],
),
),
),
),
);
}
_ToastColorScheme _toastColorScheme(ToastType type) {
switch (type) {
case ToastType.success:
return const _ToastColorScheme(
background: Color(0xFFECFDF5),
border: Color(0xFFA7F3D0),
foreground: Color(0xFF065F46),
);
case ToastType.error:
return const _ToastColorScheme(
background: Color(0xFFFFF1F2),
border: Color(0xFFFDA4AF),
foreground: Color(0xFF9F1239),
);
case ToastType.info:
return const _ToastColorScheme(
background: Color(0xFFEFF6FF),
border: Color(0xFFBFDBFE),
foreground: Color(0xFF1E40AF),
);
}
}
IconData _toastIcon(ToastType type) {
switch (type) {
case ToastType.success:
return Icons.check_circle_outline;
case ToastType.error:
return Icons.error_outline;
case ToastType.info:
return Icons.info_outline;
}
}
}
class _ToastColorScheme {
const _ToastColorScheme({
required this.background,
required this.border,
required this.foreground,
});
final Color background;
final Color border;
final Color foreground;
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/ui/toast_service.dart';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
);
ToastService.error('Invalid Password. Access Denied.');
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
);
ToastService.success('User created successfully!');
_formKey.currentState!.reset();
_loginIdController.clear();
_emailController.clear();
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
ToastService.error('Error: $e');
}
} finally {
if (mounted) setState(() => _isLoading = false);

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/ui/toast_service.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@@ -108,12 +109,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password'),
backgroundColor: Colors.red,
),
);
ToastService.error('Invalid Password');
context.go(buildLocalizedHomePath(Uri.base));
}
}
@@ -343,16 +339,12 @@ class _UserManagementScreenState extends State<UserManagementScreen>
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
ToastService.error(msg);
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
ToastService.success(msg);
}
@override

View File

@@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart';
import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart';
import 'package:userfront/core/ui/toast_service.dart';
class ConsentScreen extends StatefulWidget {
final String consentChallenge;
@@ -187,16 +188,11 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) {
setState(() => _isSubmitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.consent.cancel.error',
fallback:
'An error occurred while cancelling consent: {{error}}',
params: {'error': '$e'},
),
),
ToastService.error(
tr(
'msg.userfront.consent.cancel.error',
fallback: 'An error occurred while cancelling consent: {{error}}',
params: {'error': '$e'},
),
);
}
@@ -237,10 +233,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
}
Widget _buildConsentCard(BuildContext context) {
final clientName =
_consentInfo?['client']?['client_name'] ??
tr('msg.userfront.consent.client_unknown');
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
? clientRawName
: (clientId != '-'
? clientId
: tr('msg.userfront.consent.client_unknown'));
final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
@@ -419,7 +418,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
)
: Text(
tr('ui.userfront.consent.accept'),
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/ui/toast_service.dart';
import 'package:userfront/i18n.dart';
class ForgotPasswordScreen extends StatefulWidget {
@@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
drySend: _drySendEnabled,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.forgot.sent')),
backgroundColor: Colors.green,
),
);
ToastService.success(tr('msg.userfront.forgot.sent'));
Navigator.of(context).pop();
}
} catch (e) {
@@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
}
bool _parseBoolParam(String? value) {

View File

@@ -18,6 +18,7 @@ import '../domain/cookie_session_policy.dart';
import '../domain/login_link_route_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
import '../../../core/ui/toast_service.dart';
class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
@@ -1153,9 +1154,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
try {
AuthProxyService.logError(message);
} catch (e) {
@@ -1165,9 +1164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showInfo(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.green),
);
ToastService.success(message);
}
void _logTokenDetails(String jwt) {

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import 'package:userfront/core/ui/toast_service.dart';
import 'qr_scan_route.dart';
@@ -23,15 +24,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
void _submit() {
final raw = _controller.text.trim();
if (raw.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.qr.permission_required',
fallback: '카메라 권한이 필요합니다.',
),
),
),
ToastService.info(
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
);
return;
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/ui/toast_service.dart';
import 'package:userfront/i18n.dart';
class ResetPasswordScreen extends StatefulWidget {
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.reset.success')),
backgroundColor: Colors.green,
),
);
ToastService.success(tr('msg.userfront.reset.success'));
context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
}
String _buildPolicyDescription() {

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
import '../domain/models.dart' hide LinkedRp;
@@ -104,14 +105,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
try {
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.revoke.success',
params: {'app': appName},
),
),
ToastService.success(
tr(
'msg.userfront.dashboard.revoke.success',
params: {'app': appName},
),
);
setState(() {
@@ -121,15 +118,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.revoke.error',
params: {'error': '$e'},
),
),
),
ToastService.error(
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
);
}
} finally {
@@ -547,12 +537,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
ToastService.info(
tr('msg.userfront.dashboard.session_id_copied'),
);
}
},
@@ -626,12 +612,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
ToastService.info(
tr('msg.userfront.dashboard.session_id_copied'),
);
}
},
@@ -1280,7 +1262,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final itemUrl = item.url;
if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl);
@@ -1290,18 +1271,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
await launchUrl(uri);
return;
}
messenger.showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_open_error')),
),
);
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
} else {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_missing')),
),
);
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
}
},
child: opaqueCard,

View File

@@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../data/models/user_profile_model.dart';
import '../../domain/notifiers/profile_notifier.dart';
@@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final FocusNode _departmentFocus = FocusNode();
final FocusNode _phoneFocus = FocusNode();
final FocusNode _phoneCodeFocus = FocusNode();
bool _nameTouched = false;
bool _departmentTouched = false;
bool _phoneTouched = false;
bool _phoneCodeTouched = false;
bool _isSavingField = false;
String? _skipAutoSaveField;
String? _fieldSaveError;
String _initialPhone = '';
bool _isPhoneChanged = false;
@@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
@override
void initState() {
super.initState();
_nameFocus.addListener(_onNameFocusChange);
_departmentFocus.addListener(_onDepartmentFocusChange);
_phoneFocus.addListener(_onPhoneFocusChange);
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
}
void _debugLog(
@@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_log.fine(parts.join(' '));
}
void _onNameFocusChange() {
if (!mounted) return;
if (!_nameFocus.hasFocus && _nameTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'name');
});
} else if (_nameFocus.hasFocus) {
_nameTouched = true;
}
}
void _onDepartmentFocusChange() {
if (!mounted) return;
_debugLog(
'department_focus_change',
field: 'department',
hasFocus: _departmentFocus.hasFocus,
);
if (!_departmentFocus.hasFocus && _departmentTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'department');
});
} else if (_departmentFocus.hasFocus) {
_departmentTouched = true;
}
}
void _onPhoneFocusChange() {
if (!mounted) return;
if (!_phoneFocus.hasFocus && _phoneTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneFocus.hasFocus) {
_phoneTouched = true;
}
}
void _onPhoneCodeFocusChange() {
if (!mounted) return;
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneCodeFocus.hasFocus) {
_phoneCodeTouched = true;
}
}
@override
void dispose() {
_nameController?.dispose();
@@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isCodeSent = false;
_isVerifying = false;
_codeController?.clear();
_phoneTouched = false;
_phoneCodeTouched = false;
}
void _startEditing(String field, UserProfile profile) {
_debugLog('start_editing', field: field);
setState(() {
_editingField = field;
_fieldSaveError = null;
if (field == 'name') {
_nameController?.text = profile.name;
} else if (field == 'department') {
@@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
_fieldSaveError = null;
});
}
@@ -269,21 +203,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
);
ToastService.info(tr('msg.userfront.profile.phone.code_sent'));
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.send_failed',
params: {'error': e.toString()},
),
),
ToastService.error(
tr(
'msg.userfront.profile.phone.send_failed',
params: {'error': e.toString()},
),
);
}
@@ -303,24 +231,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
);
}
if (_editingField == 'phone') {
await _saveField(profile);
ToastService.success(tr('msg.userfront.profile.phone.verified'));
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verify_failed',
params: {'error': e.toString()},
),
),
ToastService.error(
tr(
'msg.userfront.profile.phone.verify_failed',
params: {'error': e.toString()},
),
);
}
@@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_newPasswordController?.clear();
_confirmPasswordController?.clear();
setState(() {
_passwordSuccess = tr('msg.userfront.profile.password.changed');
_passwordSuccess = null;
});
ToastService.success(tr('msg.userfront.profile.password.changed'));
} catch (e) {
final message = e.toString().replaceFirst('Exception: ', '');
setState(() {
@@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
params: {'error': message},
);
});
ToastService.error(
tr(
'msg.userfront.profile.password.change_failed',
params: {'error': message},
),
);
} finally {
if (mounted) {
setState(() => _isPasswordSaving = false);
@@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
}
void _autoSaveIfEditing(UserProfile profile, String field) {
if (_editingField != field) return;
if (_skipAutoSaveField == field) {
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
_skipAutoSaveField = null;
return;
}
if (_isVerifying) {
_debugLog('autosave_skip', field: field, reason: 'verifying');
return;
}
if (_isSavingField) {
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
return;
}
if (!_hasFieldChanged(profile, field)) {
_debugLog(
'autosave_skip',
field: field,
reason: 'unchanged',
changed: false,
);
setState(() {
if (field == 'phone') {
_resetPhoneState();
}
_editingField = null;
if (field == 'name') {
_nameTouched = false;
} else if (field == 'department') {
_departmentTouched = false;
}
});
return;
}
_debugLog('autosave_trigger', field: field, changed: true);
_saveField(profile);
}
void _handlePhoneFocusChange(UserProfile profile) {
if (_editingField != 'phone') return;
if (_skipAutoSaveField == 'phone') {
_skipAutoSaveField = null;
return;
}
if (_isVerifying) return;
if (_isSavingField) return;
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
if (!_hasFieldChanged(profile, 'phone')) {
setState(() {
_resetPhoneState();
_editingField = null;
});
return;
}
_saveField(profile);
}
bool _hasFieldChanged(UserProfile profile, String field) {
if (field == 'name') {
return (_nameController?.text.trim() ?? '') != profile.name;
@@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_debugLog('save_skip', reason: 'saving_in_flight');
return;
}
setState(() {
_fieldSaveError = null;
});
final currentField = _editingField!;
final nextName = currentField == 'name'
@@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (currentField == 'name' && nextName.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_name');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.name_required');
});
return;
}
if (currentField == 'department' && nextDepartment.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_department');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.profile.department_required')),
),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.department_required');
});
return;
}
if (currentField == 'phone') {
if (nextPhone.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.phone_required');
});
return;
}
if (_isPhoneChanged && !_isPhoneVerified) {
@@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
field: currentField,
reason: 'phone_not_verified',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.profile.phone_verify_required')),
),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
});
return;
}
}
@@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
});
return;
}
_isSavingField = true;
setState(() {
_isSavingField = true;
});
_debugLog('save_dispatch', field: currentField, changed: true);
try {
@@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
});
_debugLog('save_success', field: currentField);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
);
ToastService.success(tr('msg.userfront.profile.update_success'));
}
} catch (e) {
_debugLog('save_failed', field: currentField, reason: e.toString());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.update_failed',
params: {'error': e.toString()},
),
),
),
);
setState(() {
_fieldSaveError = tr(
'msg.userfront.profile.update_failed',
params: {'error': e.toString().replaceFirst('Exception: ', '')},
);
});
}
} finally {
_isSavingField = false;
if (mounted) {
setState(() {
_isSavingField = false;
});
}
}
}
@@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
final hasChanged = _hasFieldChanged(profile, field);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
@@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: label,
errorText: _fieldSaveError,
),
),
),
const SizedBox(width: 12),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = field;
},
child: OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
ElevatedButton(
key: Key('profile-$field-save-button'),
onPressed: isUpdating || !hasChanged || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
),
const SizedBox(width: 8),
OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
],
),
@@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
final hasChanged = _hasFieldChanged(profile, 'phone');
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
@@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: '01012345678',
errorText: _fieldSaveError,
suffixIcon: _isPhoneVerified
? const Icon(Icons.check_circle, color: Colors.green)
: null,
@@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(width: 8),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = 'phone';
},
child: OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
ElevatedButton(
onPressed: isUpdating || !canSave || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
],
),

View File

@@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
import 'core/i18n/toml_asset_loader.dart';
import 'core/ui/toast_service.dart';
import 'package:logging/logging.dart';
import 'features/auth/presentation/consent_screen.dart';
import 'i18n.dart';
@@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget {
localizationsDelegates: delegates,
supportedLocales: supportedLocales,
locale: locale,
builder: (context, child) {
return Stack(
children: [if (child != null) child, const ToastViewport()],
);
},
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
// Mocking the profile notifier
class MockProfileNotifier extends ProfileNotifier {
UserProfile? _profile;
bool updateCalled = false;
String? updatedName;
@override
Future<UserProfile?> build() async {
_profile = UserProfile(
id: 'test-id',
email: 'test@example.com',
name: 'Original Name',
phone: '01012345678',
department: 'Dev',
affiliationType: 'employee',
companyCode: 'C100',
);
return _profile;
}
@override
Future<UserProfile?> loadProfile() async {
state = const AsyncValue.loading();
state = AsyncValue.data(_profile);
return _profile;
}
@override
Future<void> updateProfile({
String? name,
String? phone,
String? department,
}) async {
updateCalled = true;
updatedName = name;
_profile = _profile!.copyWith(
name: name ?? _profile!.name,
phone: phone ?? _profile!.phone,
department: department ?? _profile!.department,
);
state = AsyncValue.data(_profile);
}
}
void main() {
testWidgets(
'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)',
(tester) async {
final recordedErrors = <FlutterErrorDetails>[];
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
final text = details.exceptionAsString();
if (text.contains('A RenderFlex overflowed')) {
return;
}
recordedErrors.add(details);
};
addTearDown(() {
FlutterError.onError = previousOnError;
});
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final mockNotifier = MockProfileNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [profileProvider.overrideWith(() => mockNotifier)],
child: const MaterialApp(home: Scaffold(body: ProfilePage())),
),
);
await tester.pumpAndSettle();
// 1. Entering edit mode
final editButton = find.byKey(const Key('profile-name-edit-button'));
expect(editButton, findsOneWidget);
await tester.tap(editButton);
await tester.pumpAndSettle();
final inputField = find.byKey(const Key('profile-name-input'));
expect(inputField, findsOneWidget);
// 2. Testing cancel flow
await tester.enterText(inputField, 'Changed Name');
await tester.pumpAndSettle();
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
await tester.tap(cancelButton);
await tester.pumpAndSettle();
// After cancellation, the field should be read-only again.
expect(find.byKey(const Key('profile-name-input')), findsNothing);
// Find text could be part of ListTile
expect(find.text('Original Name'), findsWidgets);
// 3. Re-enter edit mode and explicitly save
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('profile-name-input')),
'Saved Name',
);
await tester.pumpAndSettle();
final saveButton = find.byKey(const Key('profile-name-save-button'));
await tester.tap(saveButton);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
FlutterError.onError = previousOnError;
// Verify the mock received the update
expect(mockNotifier.updateCalled, isTrue);
expect(mockNotifier.updatedName, 'Saved Name');
expect(recordedErrors, isEmpty);
},
);
}