forked from baron/baron-sso
링크로 로그인 수정
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
|
||||
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-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) 실행 커맨드
|
||||
- Backend 전체 테스트: `cd backend && go test ./...`
|
||||
@@ -35,6 +36,7 @@
|
||||
- UserFront 테스트: `cd userfront && flutter test`
|
||||
- AdminFront E2E: `cd adminfront && npm test`
|
||||
- DevFront E2E: `cd devfront && npm test`
|
||||
- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행
|
||||
|
||||
## 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`
|
||||
- 기준: 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 '../domain/login_challenge_resolver.dart';
|
||||
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';
|
||||
|
||||
@@ -111,8 +112,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final loginIdParam = uri.queryParameters['loginId'];
|
||||
final codeParam = uri.queryParameters['code'];
|
||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||
final hasShortCodePath =
|
||||
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
||||
final shortCodeFromPath = extractLoginShortCode(uri);
|
||||
final hasShortCodePath = shortCodeFromPath != null;
|
||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||
final hasVerificationToken =
|
||||
widget.verificationToken != null || hasTokenParam;
|
||||
@@ -122,8 +123,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final notice = uri.queryParameters['notice'];
|
||||
|
||||
if (hasShortCodePath) {
|
||||
final shortCode = uri.pathSegments[1];
|
||||
_verifyShortCode(shortCode);
|
||||
_verifyShortCode(shortCodeFromPath);
|
||||
}
|
||||
if (hasLoginCode) {
|
||||
_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';
|
||||
import 'package:go_router/go_router.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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'qr_scan_screen_stub.dart'
|
||||
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||
|
||||
@@ -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/reset_password_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/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
@@ -328,25 +329,7 @@ final _router = GoRouter(
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(uri);
|
||||
|
||||
// Precise public path detection
|
||||
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');
|
||||
final isPublicPath = isPublicAuthPath(path, uri);
|
||||
|
||||
if (isPublicPath) {
|
||||
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