forked from baron/baron-sso
링크로 로그인 수정
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
|
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
|
||||||
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
||||||
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md`
|
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md`
|
||||||
|
- UserFront WASM Playwright E2E 확장 계획: `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`
|
||||||
|
|
||||||
## 4) 실행 커맨드
|
## 4) 실행 커맨드
|
||||||
- Backend 전체 테스트: `cd backend && go test ./...`
|
- Backend 전체 테스트: `cd backend && go test ./...`
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
- UserFront 테스트: `cd userfront && flutter test`
|
- UserFront 테스트: `cd userfront && flutter test`
|
||||||
- AdminFront E2E: `cd adminfront && npm test`
|
- AdminFront E2E: `cd adminfront && npm test`
|
||||||
- DevFront E2E: `cd devfront && npm test`
|
- DevFront E2E: `cd devfront && npm test`
|
||||||
|
- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행
|
||||||
|
|
||||||
## 5) 유지 원칙
|
## 5) 유지 원칙
|
||||||
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
||||||
|
|||||||
69
docs/test-plan/userfront-wasm-e2e-expansion-plan.md
Normal file
69
docs/test-plan/userfront-wasm-e2e-expansion-plan.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# UserFront WASM Playwright E2E 확장 계획
|
||||||
|
|
||||||
|
- 작성일: 2026-02-23
|
||||||
|
- 대상: `userfront` (Flutter Web WASM 산출물)
|
||||||
|
- 목적: 로그인/리다이렉트/QR 흐름의 브라우저 실동작 회귀를 CI에서 자동 검증
|
||||||
|
|
||||||
|
## 1) 전제
|
||||||
|
- `flutter build web --wasm --release` 산출물(`userfront/build/web`)을 정적 서버로 서빙합니다.
|
||||||
|
- Playwright는 해당 URL로 접속해 E2E를 수행합니다.
|
||||||
|
- 카메라/QR은 실장비 의존도를 제거하기 위해 브라우저 API mock 기반 케이스를 기본으로 구성합니다.
|
||||||
|
|
||||||
|
## 2) 확장 범위 (우선순위)
|
||||||
|
1. Locale 진입/리다이렉트
|
||||||
|
- `/` 진입 시 `/{locale}`로 이동
|
||||||
|
- 비로그인 상태 `/{locale}` 진입 시 `/{locale}/signin` 이동
|
||||||
|
- 로그인 상태 `/{locale}` 진입 시 `/{locale}/dashboard` 이동
|
||||||
|
|
||||||
|
2. 로그인 성공/실패 및 새로고침 회귀
|
||||||
|
- 정상 로그인 후 `/{locale}/dashboard` 진입
|
||||||
|
- 대시보드 진입 후 새로고침 시 `signin`으로 튕기지 않음
|
||||||
|
- 비밀번호 오류 시 코드 기반 에러 표시 동작 확인
|
||||||
|
|
||||||
|
3. 비밀번호 재설정 플로우
|
||||||
|
- reset 링크 진입 후 비밀번호 변경
|
||||||
|
- 변경된 비밀번호로 즉시 로그인 가능
|
||||||
|
|
||||||
|
4. QR 로그인 (웹 로그인 페이지)
|
||||||
|
- QR init/poll 기본 플로우
|
||||||
|
- 만료/재발급 동작
|
||||||
|
|
||||||
|
5. QR 스캔/승인 (WASM)
|
||||||
|
- `/scan`에서 스캔 결과가 `/{locale}/approve?ref=...`로 전달됨
|
||||||
|
- BarcodeDetector 미지원/카메라 실패 시 수동 입력 fallback 동작
|
||||||
|
- approve 성공 시 dashboard 이동
|
||||||
|
|
||||||
|
6. 널체크 회복 경로 회귀
|
||||||
|
- `/ko` 경로에서 null-check 예외 발생 시 recovery target(`/{locale}/signin`) 이동 보장
|
||||||
|
|
||||||
|
## 3) 구현 단계
|
||||||
|
### Phase 0. E2E 실행 기반
|
||||||
|
- `userfront-e2e/` (Playwright) 추가
|
||||||
|
- `BASE_URL`/`LOCALE`/`MOCK_AUTH` 환경변수 표준화
|
||||||
|
- CI job: WASM build 산출물 서빙 + Playwright 실행
|
||||||
|
|
||||||
|
### Phase 1. 인증/리다이렉트 핵심 회귀
|
||||||
|
- 범위 1~2 구현
|
||||||
|
- 실패 재현 케이스를 먼저 작성(Failing test first)
|
||||||
|
|
||||||
|
### Phase 2. 비밀번호 재설정 회귀
|
||||||
|
- 범위 3 구현
|
||||||
|
- 성공/실패 케이스 분리
|
||||||
|
|
||||||
|
### Phase 3. QR 흐름 회귀
|
||||||
|
- 범위 4~5 구현
|
||||||
|
- BarcodeDetector/getUserMedia mock fixture 도입
|
||||||
|
|
||||||
|
### Phase 4. 에러/회복 회귀
|
||||||
|
- 범위 6 구현
|
||||||
|
- null-check 복구 라우팅 검증
|
||||||
|
|
||||||
|
## 4) 완료 기준
|
||||||
|
- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다.
|
||||||
|
- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
|
||||||
|
- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
|
||||||
|
|
||||||
|
## 5) 운영 원칙
|
||||||
|
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
|
||||||
|
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
|
||||||
|
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
|
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
|
||||||
- 기준: Playwright `test(...)` 케이스 전수
|
- 기준: Playwright `test(...)` 케이스 전수
|
||||||
|
- 참고: UserFront WASM E2E 확장 계획은 `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`에서 별도 관리
|
||||||
|
|
||||||
| 파일 | 테스트 | 역할 |
|
| 파일 | 테스트 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
|
bool isPublicAuthPath(String path, Uri uri) {
|
||||||
|
return path == '/signin' ||
|
||||||
|
path == '/signup' ||
|
||||||
|
path == '/login' ||
|
||||||
|
path == '/registration' ||
|
||||||
|
path == '/verify' ||
|
||||||
|
path == '/verification' ||
|
||||||
|
path.startsWith('/verify/') ||
|
||||||
|
path.startsWith('/l/') ||
|
||||||
|
path == '/approve' ||
|
||||||
|
path.startsWith('/ql/') ||
|
||||||
|
path == '/forgot-password' ||
|
||||||
|
path == '/recovery' ||
|
||||||
|
path == '/reset-password' ||
|
||||||
|
path == '/error' ||
|
||||||
|
path == '/settings' ||
|
||||||
|
path == '/consent' ||
|
||||||
|
path.startsWith('/consent/') ||
|
||||||
|
uri.path.contains('/consent');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? extractLoginShortCode(Uri uri) {
|
||||||
|
final normalizedPath = stripLocalePath(uri);
|
||||||
|
final segments = normalizedPath
|
||||||
|
.split('/')
|
||||||
|
.where((segment) => segment.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (segments.length < 2 || segments.first != 'l') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return segments[1];
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import '../../../core/services/oidc_redirect_guard.dart';
|
|||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../domain/login_challenge_resolver.dart';
|
import '../domain/login_challenge_resolver.dart';
|
||||||
import '../domain/cookie_session_policy.dart';
|
import '../domain/cookie_session_policy.dart';
|
||||||
|
import '../domain/login_link_route_policy.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../../../core/services/web_window.dart';
|
import '../../../core/services/web_window.dart';
|
||||||
|
|
||||||
@@ -111,8 +112,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||||
final hasShortCodePath =
|
final shortCodeFromPath = extractLoginShortCode(uri);
|
||||||
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
final hasShortCodePath = shortCodeFromPath != null;
|
||||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||||
final hasVerificationToken =
|
final hasVerificationToken =
|
||||||
widget.verificationToken != null || hasTokenParam;
|
widget.verificationToken != null || hasTokenParam;
|
||||||
@@ -122,8 +123,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final notice = uri.queryParameters['notice'];
|
final notice = uri.queryParameters['notice'];
|
||||||
|
|
||||||
if (hasShortCodePath) {
|
if (hasShortCodePath) {
|
||||||
final shortCode = uri.pathSegments[1];
|
_verifyShortCode(shortCodeFromPath);
|
||||||
_verifyShortCode(shortCode);
|
|
||||||
}
|
}
|
||||||
if (hasLoginCode) {
|
if (hasLoginCode) {
|
||||||
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
enum QrCameraBootstrapStatus {
|
||||||
|
ready,
|
||||||
|
detectorUnsupported,
|
||||||
|
permissionError,
|
||||||
|
cameraError,
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrCameraBootstrapResult {
|
||||||
|
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||||
|
|
||||||
|
final QrCameraBootstrapStatus status;
|
||||||
|
final String errorDetail;
|
||||||
|
|
||||||
|
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||||
|
typedef QrStopCamera = Future<void> Function();
|
||||||
|
|
||||||
|
bool isQrPermissionError(Object error) {
|
||||||
|
final raw = error.toString();
|
||||||
|
return raw.contains('NotAllowedError') ||
|
||||||
|
raw.contains('PermissionDeniedError') ||
|
||||||
|
raw.contains('SecurityError');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||||
|
required bool hasBarcodeDetector,
|
||||||
|
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||||
|
required QrStopCamera stopCamera,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await openCameraAndPlay();
|
||||||
|
if (!hasBarcodeDetector) {
|
||||||
|
await stopCamera();
|
||||||
|
return const QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.detectorUnsupported,
|
||||||
|
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||||
|
} catch (e) {
|
||||||
|
if (isQrPermissionError(e)) {
|
||||||
|
return QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.permissionError,
|
||||||
|
errorDetail: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.cameraError,
|
||||||
|
errorDetail: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
userfront/lib/features/auth/presentation/qr_scan_route.dart
Normal file
17
userfront/lib/features/auth/presentation/qr_scan_route.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
|
String buildQrApprovePath(
|
||||||
|
String scannedValue, {
|
||||||
|
String? localeCode,
|
||||||
|
Uri? currentUri,
|
||||||
|
}) {
|
||||||
|
final value = scannedValue.trim();
|
||||||
|
final explicitLocale = localeCode?.trim();
|
||||||
|
final uri = currentUri ?? Uri.base;
|
||||||
|
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||||
|
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||||
|
: normalizeLocaleCode(
|
||||||
|
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||||
|
);
|
||||||
|
return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
|
||||||
|
}
|
||||||
@@ -1,30 +1,2 @@
|
|||||||
import 'package:flutter/material.dart';
|
export 'qr_scan_screen_stub.dart'
|
||||||
import 'package:go_router/go_router.dart';
|
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
|
||||||
|
|
||||||
class QRScanScreen extends StatefulWidget {
|
|
||||||
const QRScanScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QRScanScreenState extends State<QRScanScreen> {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: const Center(
|
|
||||||
child: Text(
|
|
||||||
'QR Scanner is temporarily disabled for WASM build stability.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
|
import 'qr_scan_route.dart';
|
||||||
|
|
||||||
|
class QRScanScreen extends StatefulWidget {
|
||||||
|
const QRScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QRScanScreenState extends State<QRScanScreen> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final raw = _controller.text.trim();
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.qr.permission_required',
|
||||||
|
fallback: '카메라 권한이 필요합니다.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go(buildQrApprovePath(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.qr.permission_error',
|
||||||
|
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('qr_scan_manual_input'),
|
||||||
|
controller: _controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'QR Payload',
|
||||||
|
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('qr_scan_submit_button'),
|
||||||
|
onPressed: _submit,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: Text(
|
||||||
|
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
Normal file
293
userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:js_interop_unsafe';
|
||||||
|
import 'dart:ui_web' as ui_web;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:userfront/i18n.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
import 'qr_camera_bootstrap_policy.dart';
|
||||||
|
import 'qr_scan_route.dart';
|
||||||
|
|
||||||
|
@JS('BarcodeDetector')
|
||||||
|
extension type _BarcodeDetector._(JSObject _) implements JSObject {
|
||||||
|
external factory _BarcodeDetector();
|
||||||
|
external JSPromise<JSArray<_DetectedBarcode>> detect(JSObject source);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension type _DetectedBarcode._(JSObject _) implements JSObject {
|
||||||
|
external String? get rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QRScanScreen extends StatefulWidget {
|
||||||
|
const QRScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QRScanScreenState extends State<QRScanScreen> {
|
||||||
|
late final web.HTMLVideoElement _videoElement;
|
||||||
|
late final String _viewType;
|
||||||
|
final TextEditingController _manualController = TextEditingController();
|
||||||
|
|
||||||
|
web.MediaStream? _stream;
|
||||||
|
_BarcodeDetector? _detector;
|
||||||
|
Timer? _scanTimer;
|
||||||
|
bool _scanInFlight = false;
|
||||||
|
bool _navigated = false;
|
||||||
|
bool _initializing = true;
|
||||||
|
String? _error;
|
||||||
|
String? _status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_viewType = 'qr-camera-${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
_videoElement = web.HTMLVideoElement()
|
||||||
|
..autoplay = true
|
||||||
|
..muted = true
|
||||||
|
..playsInline = true
|
||||||
|
..style.border = '0'
|
||||||
|
..style.width = '100%'
|
||||||
|
..style.height = '100%'
|
||||||
|
..style.objectFit = 'cover';
|
||||||
|
|
||||||
|
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||||
|
return _videoElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
unawaited(_initializeScanner());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scanTimer?.cancel();
|
||||||
|
_stopCamera();
|
||||||
|
_manualController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeScanner() async {
|
||||||
|
setState(() {
|
||||||
|
_initializing = true;
|
||||||
|
_error = null;
|
||||||
|
_status = tr('ui.userfront.qr.title', fallback: 'Scan QR Code');
|
||||||
|
});
|
||||||
|
final hasBarcodeDetector = globalContext
|
||||||
|
.hasProperty('BarcodeDetector'.toJS)
|
||||||
|
.toDart;
|
||||||
|
|
||||||
|
final result = await bootstrapQrCamera(
|
||||||
|
hasBarcodeDetector: hasBarcodeDetector,
|
||||||
|
openCameraAndPlay: () async {
|
||||||
|
final constraints = web.MediaStreamConstraints(
|
||||||
|
video: web.MediaTrackConstraints(facingMode: 'environment'.toJS),
|
||||||
|
audio: false.toJS,
|
||||||
|
);
|
||||||
|
_stream = await web.window.navigator.mediaDevices
|
||||||
|
.getUserMedia(constraints)
|
||||||
|
.toDart;
|
||||||
|
_videoElement.srcObject = _stream;
|
||||||
|
await _videoElement.play().toDart;
|
||||||
|
},
|
||||||
|
stopCamera: () async {
|
||||||
|
_stopCamera();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.status) {
|
||||||
|
case QrCameraBootstrapStatus.ready:
|
||||||
|
_detector = _BarcodeDetector();
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_status = tr(
|
||||||
|
'msg.userfront.login.qr.scan_hint',
|
||||||
|
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_startScanLoop();
|
||||||
|
break;
|
||||||
|
case QrCameraBootstrapStatus.detectorUnsupported:
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_status = tr(
|
||||||
|
'msg.userfront.login.qr.scan_hint',
|
||||||
|
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||||
|
);
|
||||||
|
_error = tr(
|
||||||
|
'msg.userfront.qr.camera_error',
|
||||||
|
fallback: 'Camera error: {{error}}',
|
||||||
|
params: {'error': result.errorDetail},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case QrCameraBootstrapStatus.permissionError:
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_error = tr(
|
||||||
|
'msg.userfront.qr.permission_error',
|
||||||
|
fallback: 'Permission request failed. Check browser/OS settings.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case QrCameraBootstrapStatus.cameraError:
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_error = tr(
|
||||||
|
'msg.userfront.qr.camera_error',
|
||||||
|
fallback: 'Camera error: {{error}}',
|
||||||
|
params: {'error': result.errorDetail},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startScanLoop() {
|
||||||
|
_scanTimer?.cancel();
|
||||||
|
_scanTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||||||
|
if (!mounted || _scanInFlight || _navigated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final detector = _detector;
|
||||||
|
if (detector == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_videoElement.readyState < web.HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanInFlight = true;
|
||||||
|
try {
|
||||||
|
final detected = await detector.detect(_videoElement).toDart;
|
||||||
|
final items = detected.toDart;
|
||||||
|
for (final item in items) {
|
||||||
|
final raw = item.rawValue?.trim();
|
||||||
|
if (raw != null && raw.isNotEmpty) {
|
||||||
|
_handleScanSuccess(raw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
_scanInFlight = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopCamera() {
|
||||||
|
final stream = _stream;
|
||||||
|
if (stream == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final tracks = stream.getTracks().toDart;
|
||||||
|
for (final track in tracks) {
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
_videoElement.srcObject = null;
|
||||||
|
_stream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleScanSuccess(String payload) {
|
||||||
|
if (_navigated || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_navigated = true;
|
||||||
|
_scanTimer?.cancel();
|
||||||
|
_stopCamera();
|
||||||
|
context.go(buildQrApprovePath(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitManual() {
|
||||||
|
final payload = _manualController.text.trim();
|
||||||
|
if (payload.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handleScanSuccess(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retry() async {
|
||||||
|
_scanTimer?.cancel();
|
||||||
|
_stopCamera();
|
||||||
|
_detector = null;
|
||||||
|
await _initializeScanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 3 / 4,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _initializing
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: HtmlElementView(viewType: _viewType),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_status != null) Text(_status!, textAlign: TextAlign.center),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _initializing ? null : _retry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('qr_scan_manual_input'),
|
||||||
|
controller: _manualController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'QR Payload',
|
||||||
|
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submitManual(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('qr_scan_submit_button'),
|
||||||
|
onPressed: _submitManual,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: Text(
|
||||||
|
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'features/auth/presentation/qr_scan_screen.dart';
|
|||||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||||
import 'features/auth/presentation/reset_password_screen.dart';
|
import 'features/auth/presentation/reset_password_screen.dart';
|
||||||
import 'features/auth/presentation/error_screen.dart';
|
import 'features/auth/presentation/error_screen.dart';
|
||||||
|
import 'features/auth/domain/login_link_route_policy.dart';
|
||||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'features/profile/presentation/pages/profile_page.dart';
|
import 'features/profile/presentation/pages/profile_page.dart';
|
||||||
@@ -328,25 +329,7 @@ final _router = GoRouter(
|
|||||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||||
final path = stripLocalePath(uri);
|
final path = stripLocalePath(uri);
|
||||||
|
|
||||||
// Precise public path detection
|
final isPublicPath = isPublicAuthPath(path, uri);
|
||||||
final isPublicPath =
|
|
||||||
path == '/signin' ||
|
|
||||||
path == '/signup' ||
|
|
||||||
path == '/login' ||
|
|
||||||
path == '/registration' ||
|
|
||||||
path == '/verify' ||
|
|
||||||
path == '/verification' ||
|
|
||||||
path.startsWith('/verify/') ||
|
|
||||||
path == '/approve' ||
|
|
||||||
path.startsWith('/ql/') ||
|
|
||||||
path == '/forgot-password' ||
|
|
||||||
path == '/recovery' ||
|
|
||||||
path == '/reset-password' ||
|
|
||||||
path == '/error' ||
|
|
||||||
path == '/settings' ||
|
|
||||||
path == '/consent' ||
|
|
||||||
path.startsWith('/consent/') ||
|
|
||||||
uri.path.contains('/consent');
|
|
||||||
|
|
||||||
if (isPublicPath) {
|
if (isPublicPath) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
33
userfront/test/login_link_route_policy_test.dart
Normal file
33
userfront/test/login_link_route_policy_test.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||||
|
import 'package:userfront/features/auth/domain/login_link_route_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('login_link_route_policy', () {
|
||||||
|
setUp(() {
|
||||||
|
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
LocaleRegistry.resetForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts short code from plain short-code route', () {
|
||||||
|
final shortCode = extractLoginShortCode(Uri.parse('/l/AB123456'));
|
||||||
|
expect(shortCode, 'AB123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts short code from localized short-code route', () {
|
||||||
|
final shortCode = extractLoginShortCode(Uri.parse('/ko/l/AB123456'));
|
||||||
|
expect(shortCode, 'AB123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats localized short-code route as public path', () {
|
||||||
|
final isPublic = isPublicAuthPath(
|
||||||
|
'/l/AB123456',
|
||||||
|
Uri.parse('/ko/l/AB123456'),
|
||||||
|
);
|
||||||
|
expect(isPublic, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
67
userfront/test/qr_camera_bootstrap_policy_test.dart
Normal file
67
userfront/test/qr_camera_bootstrap_policy_test.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/auth/presentation/qr_camera_bootstrap_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('bootstrapQrCamera', () {
|
||||||
|
test('권한 허용 후 카메라 실행 성공 시 ready 상태를 반환한다', () async {
|
||||||
|
var stopCalled = false;
|
||||||
|
|
||||||
|
final result = await bootstrapQrCamera(
|
||||||
|
hasBarcodeDetector: true,
|
||||||
|
openCameraAndPlay: () async {},
|
||||||
|
stopCamera: () async {
|
||||||
|
stopCalled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, QrCameraBootstrapStatus.ready);
|
||||||
|
expect(stopCalled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('권한 허용 후 play 단계 오류는 cameraError로 분류한다', () async {
|
||||||
|
var stopCalled = false;
|
||||||
|
|
||||||
|
final result = await bootstrapQrCamera(
|
||||||
|
hasBarcodeDetector: true,
|
||||||
|
openCameraAndPlay: () async {
|
||||||
|
throw Exception('NotReadableError: Could not start video source');
|
||||||
|
},
|
||||||
|
stopCamera: () async {
|
||||||
|
stopCalled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, QrCameraBootstrapStatus.cameraError);
|
||||||
|
expect(result.errorDetail, contains('NotReadableError'));
|
||||||
|
expect(stopCalled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('권한 거부 오류는 permissionError로 분류한다', () async {
|
||||||
|
final result = await bootstrapQrCamera(
|
||||||
|
hasBarcodeDetector: true,
|
||||||
|
openCameraAndPlay: () async {
|
||||||
|
throw Exception('NotAllowedError: Permission denied');
|
||||||
|
},
|
||||||
|
stopCamera: () async {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, QrCameraBootstrapStatus.permissionError);
|
||||||
|
expect(result.errorDetail, contains('NotAllowedError'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detector 미지원이면 카메라를 정리하고 detectorUnsupported를 반환한다', () async {
|
||||||
|
var stopCalled = false;
|
||||||
|
|
||||||
|
final result = await bootstrapQrCamera(
|
||||||
|
hasBarcodeDetector: false,
|
||||||
|
openCameraAndPlay: () async {},
|
||||||
|
stopCamera: () async {
|
||||||
|
stopCalled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, QrCameraBootstrapStatus.detectorUnsupported);
|
||||||
|
expect(stopCalled, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
27
userfront/test/qr_scan_route_test.dart
Normal file
27
userfront/test/qr_scan_route_test.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/auth/presentation/qr_scan_route.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('buildQrApprovePath', () {
|
||||||
|
test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () {
|
||||||
|
final result = buildQrApprovePath(
|
||||||
|
' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ',
|
||||||
|
localeCode: 'ko',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
'/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('현재 URI에서 locale을 추출한다', () {
|
||||||
|
final result = buildQrApprovePath(
|
||||||
|
'abc123',
|
||||||
|
currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, '/en/approve?ref=abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
userfront/test/qr_scan_screen_test.dart
Normal file
16
userfront/test/qr_scan_screen_test.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/auth/presentation/qr_scan_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: QRScanScreen()));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('QR Scanner is temporarily disabled for WASM build stability.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user