1
0
forked from baron/baron-sso

userfront로 리펙토링 완료

This commit is contained in:
Lectom C Han
2026-01-28 08:28:25 +09:00
parent 6d88c81217
commit 1aaa772907
154 changed files with 339 additions and 314 deletions

34
userfront/.env.sample Normal file
View File

@@ -0,0 +1,34 @@
# ==========================================
# Baron SSO - Unified Environment Configuration
# ==========================================
# --- General System ---
APP_ENV=development
TZ=Asia/Seoul
# --- Infrastructure Ports ---
DB_PORT=5432
CLICKHOUSE_PORT_HTTP=8123
CLICKHOUSE_PORT_NATIVE=9000
BACKEND_PORT=3000
USERFRONT_PORT=5000
# --- Database Credentials (PostgreSQL) ---
DB_USER=baron
DB_PASSWORD=password
DB_NAME=baron_sso
# --- Backend Configuration ---
# Must be 32 bytes. Generate with `openssl rand -hex 32`
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
REDIS_ADDR=redis:6379
# --- Frontend Configuration ---
# Descope Project ID (Required for Auth)
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
# --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
NAVER_SENDER_PHONE_NUMBER=...

45
userfront/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

36
userfront/.metadata Normal file
View File

@@ -0,0 +1,36 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: ios
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: web
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

19
userfront/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Stage 1: Build Flutter
FROM ghcr.io/cirruslabs/flutter:stable AS build
# ENV RUN_FLUTTER_AS_ROOT=true
WORKDIR /app
COPY . .
# Get dependencies and build for web
RUN flutter pub get
RUN touch .env
RUN flutter build web --release --no-tree-shake-icons
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/build/web /usr/share/nginx/html
# Copy custom Nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
userfront/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "kr.co.baroncs.userfront"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "kr.co.baroncs.userfront"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,46 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<application
android:label="userfront"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package kr.co.baroncs.userfront
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

34
userfront/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.baroncs.userfront;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Frontend</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>userfront</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app requires camera access to scan QR codes for login.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
class AuthNotifier extends ChangeNotifier {
static final AuthNotifier instance = AuthNotifier();
void notify() {
notifyListeners();
}
}

View File

@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuditService {
static String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<void> logEvent({
required String userId,
required String eventType,
required String status,
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'user_id': userId,
'event_type': eventType,
'status': status,
'details': details,
'timestamp': DateTime.now().toIso8601String(),
}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
print("Audit log sent successfully");
} else {
print("Failed to send audit log: ${response.statusCode} ${response.body}");
}
} catch (e) {
print("Error sending audit log: $e");
}
}
}

View File

@@ -0,0 +1,468 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuthProxyService {
static String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<Map<String, dynamic>> fetchPasswordPolicy() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
final response = await http.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to fetch password policy');
}
}
static Future<Map<String, dynamic>> initEnchantedLink(String loginId, {String? method}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = {
'loginId': loginId,
'uri': userfrontUrl,
};
if (method != null) {
body['method'] = method;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to init login: ${response.body}');
}
}
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Polling failed: ${response.body}');
}
}
static Future<Map<String, dynamic>> verifyMagicLink(String token) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Verification failed: ${response.body}');
}
}
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'loginId': loginId,
'password': password,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to login');
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'loginId': loginId}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to initiate password reset');
}
}
static Future<Map<String, dynamic>> completePasswordReset({
String? loginId,
String? token,
required String newPassword,
}) async {
final query = <String, String>{};
if (loginId != null && loginId.isNotEmpty) {
query['loginId'] = loginId;
}
if (token != null && token.isNotEmpty) {
query['token'] = token;
}
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'newPassword': newPassword}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to complete password reset');
}
}
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to send SMS: ${response.body}');
}
}
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
'code': code,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to verify code: ${response.body}');
}
}
static Future<Map<String, dynamic>> initQrLogin() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to init QR login: ${response.body}');
}
}
static Future<Map<String, dynamic>> pollQrStatus(String pendingRef) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'pendingRef': pendingRef}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('QR Polling failed: ${response.body}');
}
}
static Future<void> approveQrLogin(String pendingRef, String token) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
'token': token,
}),
);
if (response.statusCode != 200) {
throw Exception('QR Approval failed: ${response.body}');
}
}
static Future<bool> checkAdminAuth(String adminPassword) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
try {
final response = await http.get(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
static Future<void> createUser({
required String loginId,
required String adminPassword,
String? email,
String? phone,
String? displayName,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
body: jsonEncode({
'loginId': loginId,
'email': email,
'phone': phone,
'displayName': displayName,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to create user: ${response.body}');
}
}
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
if (query != null && query.isNotEmpty) {
uri = uri.replace(queryParameters: {'text': query});
}
final response = await http.get(
uri,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['users'] ?? [];
} else {
throw Exception('Failed to list users: ${response.body}');
}
}
static Future<void> deleteUser(String adminPassword, String loginId) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final response = await http.delete(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
);
if (response.statusCode != 200) {
throw Exception('Failed to delete user: ${response.body}');
}
}
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
final response = await http.patch(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
body: jsonEncode({'status': status}),
);
if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.body}');
}
}
static Future<void> updateUserDetails({
required String adminPassword,
required String loginId,
String? email,
String? phone,
String? displayName,
}) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final body = <String, dynamic>{};
if (email != null) body['email'] = email;
if (phone != null) body['phone'] = phone;
if (displayName != null) body['displayName'] = displayName;
final response = await http.patch(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception('Failed to update user: ${response.body}');
}
}
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
final url = Uri.parse('$_baseUrl/api/v1/client-log');
try {
await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'level': level,
'message': message,
if (data != null) 'data': data,
}),
);
} catch (_) {
// Ignore logging errors to prevent loops
}
}
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async {
final data = <String, dynamic>{};
if (error != null) data['error'] = error.toString();
if (stackTrace != null) data['stack'] = stackTrace.toString();
await sendLog('ERROR', message, data: data);
}
// --- Signup Methods ---
static Future<bool> checkEmailAvailability(String email) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-email');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['available'] ?? false;
}
return false;
}
static Future<void> sendSignupCode(String target, String type) async {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'target': target}),
);
if (response.statusCode != 200) {
throw Exception('Failed to send code: ${response.body}');
}
}
static Future<bool> verifySignupCode(String target, String type, String code) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'target': target,
'type': type,
'code': code,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['success'] ?? false;
}
return false;
}
static Future<void> signup({
required String email,
required String password,
required String name,
required String phone,
required String affiliationType,
String? companyCode,
required String department,
required bool termsAccepted,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': email,
'password': password,
'name': name,
'phone': phone,
'affiliationType': affiliationType,
if (companyCode != null) 'companyCode': companyCode,
'department': department,
'termsAccepted': termsAccepted,
}),
);
if (response.statusCode != 200) {
final error = jsonDecode(response.body)['error'] ?? 'Signup failed';
throw Exception(error);
}
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart' as std_log;
import 'package:logger/logger.dart' as pretty_log;
import 'auth_proxy_service.dart';
/// Global Logger Service for Baron SSO Frontend
class LoggerService {
static final LoggerService _instance = LoggerService._internal();
factory LoggerService() => _instance;
late final pretty_log.Logger _prettyLogger;
LoggerService._internal() {
// 1. Initialize Pretty Logger for Dev
_prettyLogger = pretty_log.Logger(
printer: pretty_log.PrettyPrinter(
methodCount: 0,
errorMethodCount: 8,
lineLength: 120,
colors: true,
printEmojis: true,
dateTimeFormat: pretty_log.DateTimeFormat.onlyTimeAndSinceStart,
),
);
// 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = kReleaseMode ? std_log.Level.INFO : std_log.Level.ALL;
std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) {
// [Production] Log as JSON
_logJson(record);
} else {
// [Development] Log using Pretty Printer
_logPretty(record);
}
});
}
/// Initialize the logger. Call this in main.dart
static void init() {
// Accessing the instance triggers the constructor
LoggerService();
std_log.Logger('BaronSSO').info('Logger initialized');
}
void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace);
} else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) {
_prettyLogger.i(record.message);
} else {
_prettyLogger.d(record.message);
}
}
void _logJson(std_log.LogRecord record) {
final logData = {
'time': record.time.toUtc().toIso8601String(), // Use UTC for consistency
'level': record.level.name,
'msg': record.message,
'svc': 'baron-userfront',
if (record.error != null) 'error': record.error.toString(),
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
};
// 1. Print to Browser Console (F12)
debugPrint(jsonEncode(logData));
// 2. Relay to Backend (Docker Terminal)
if (record.level >= std_log.Level.INFO) {
AuthProxyService.sendLog(
record.level.name,
record.message,
data: {
'client_time': record.time.toUtc().toIso8601String(),
'logger': record.loggerName,
if (record.error != null) 'error': record.error.toString(),
},
);
}
}
}

View File

@@ -0,0 +1,13 @@
import 'web_auth_integration_stub.dart'
if (dart.library.html) 'web_auth_integration_web.dart';
abstract class WebAuthIntegration {
static void sendLoginSuccess(String token) {
// Platform-specific implementation
implSendLoginSuccess(token);
}
static bool isPopup() {
return implIsPopup();
}
}

View File

@@ -0,0 +1,8 @@
void implSendLoginSuccess(String token) {
// No-op on non-web platforms
print("Not on web: Login Success with token: $token");
}
bool implIsPopup() {
return false;
}

View File

@@ -0,0 +1,27 @@
import 'dart:html' as html;
import 'dart:async';
void implSendLoginSuccess(String token) {
final message = {'type': 'LOGIN_SUCCESS', 'token': token};
if (html.window.opener != null) {
try {
html.window.opener!.postMessage(message, '*');
print("Sent login success message to opener");
} catch (e) {
print("Failed to postMessage: $e");
}
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
html.window.close();
});
} else {
// Should not happen given isPopup check, but as fallback:
print("No opener found during popup flow.");
}
}
bool implIsPopup() {
return html.window.opener != null;
}

View File

@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@override
State<CreateUserScreen> createState() => _CreateUserScreenState();
}
class _CreateUserScreenState extends State<CreateUserScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _loginIdController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
bool _isLoading = false;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
}
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
// Show blocking dialog
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false, // User must enter password or leave
builder: (context) => AlertDialog(
title: const Text("Admin Authentication Required"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Please enter the admin password to access this page."),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null), // Cancel
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, passwordController.text),
child: const Text("Enter"),
),
],
),
);
// If cancelled or empty
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/'); // Kick out
return;
}
// Verify against Backend
setState(() => _isLoading = true);
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
setState(() => _isLoading = false);
if (isValid) {
if (mounted) {
setState(() {
_isAuthorized = true;
_verifiedAdminPassword = inputPassword;
});
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red),
);
context.go('/'); // Kick out
}
}
}
@override
void dispose() {
_loginIdController.dispose();
_emailController.dispose();
_phoneController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_verifiedAdminPassword == null) return; // Should not happen
setState(() => _isLoading = true);
String loginId = _loginIdController.text.trim();
if (!loginId.contains('@')) {
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
phone = '+82${phone.substring(1)}';
}
}
try {
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
phone: phone,
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green),
);
_formKey.currentState!.reset();
_loginIdController.clear();
_emailController.clear();
_phoneController.clear();
_nameController.clear();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
// Hide content until authorized
if (!_isAuthorized) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
"Create New User",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _loginIdController,
decoration: const InputDecoration(
labelText: "Login ID (Required)",
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: "Display Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: "Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
helperText: "Start with 010 (e.g., 010-1234-5678)",
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text("Create User"),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../../core/services/auth_proxy_service.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@override
State<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
bool _isLoading = false;
// --- List Tab Variables ---
List<dynamic> _users = [];
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
// --- Create Tab Controllers ---
final _formKey = GlobalKey<FormState>();
final TextEditingController _createLoginIdController = TextEditingController();
final TextEditingController _createEmailController = TextEditingController();
final TextEditingController _createPhoneController = TextEditingController();
final TextEditingController _createNameController = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_debounce?.cancel();
_createLoginIdController.dispose();
_createEmailController.dispose();
_createPhoneController.dispose();
_createNameController.dispose();
super.dispose();
}
// --- Authentication ---
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("Admin Authentication Required"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Please enter the admin password."),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
],
),
);
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/');
return;
}
setState(() => _isLoading = true);
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
setState(() => _isLoading = false);
if (isValid) {
if (mounted) {
setState(() {
_isAuthorized = true;
_verifiedAdminPassword = inputPassword;
});
_loadUsers();
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
context.go('/');
}
}
}
// --- User List Logic ---
Future<void> _loadUsers({String? query}) async {
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
try {
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
setState(() => _users = users);
} catch (e) {
_showError("Failed to load users: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
_loadUsers(query: query);
});
}
Future<void> _deleteUser(String loginId) async {
if (_verifiedAdminPassword == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Delete User"),
content: Text("Are you sure you want to delete $loginId? This cannot be undone."),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete")
),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
try {
await AuthProxyService.deleteUser(_verifiedAdminPassword!, loginId);
_showSuccess("User deleted");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Failed to delete: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _toggleStatus(String loginId, String currentStatus) async {
if (_verifiedAdminPassword == null) return;
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
setState(() => _isLoading = true);
try {
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
_showSuccess("User status updated to $newStatus");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Failed to update status: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _editUser(Map user) async {
if (_verifiedAdminPassword == null) return;
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
if (loginId.isEmpty) return;
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text("Edit User: $loginId"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
phone = '+82${phone.substring(1)}';
}
}
try {
await AuthProxyService.updateUserDetails(
adminPassword: _verifiedAdminPassword!,
loginId: loginId,
displayName: nameController.text.trim(),
email: emailController.text.trim(),
phone: phone,
);
_showSuccess("User updated successfully");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Update failed: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// --- Create User Logic ---
Future<void> _createUserSubmit() async {
if (!_formKey.currentState!.validate()) return;
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
String loginId = _createLoginIdController.text.trim();
if (!loginId.contains('@')) {
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
phone = '+82${phone.substring(1)}';
}
}
try {
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
phone: phone,
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
);
_showSuccess("User created successfully");
_formKey.currentState!.reset();
_createLoginIdController.clear();
_createEmailController.clear();
_createPhoneController.clear();
_createNameController.clear();
// Switch to list tab and reload
_tabController.animateTo(0);
_loadUsers();
} catch (e) {
_showError("Error: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
}
@override
Widget build(BuildContext context) {
if (!_isAuthorized) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('User Management'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.list), text: "User List"),
Tab(icon: Icon(Icons.person_add), text: "Create User"),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildUserListTab(),
_buildCreateUserTab(),
],
),
);
}
Widget _buildUserListTab() {
return Column(
children: [
if (_isLoading) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: "Search Users",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
hintText: "Email, Phone, or Name",
),
onChanged: _onSearchChanged,
),
),
Expanded(
child: _users.isEmpty
? const Center(child: Text("No users found."))
: ListView.separated(
itemCount: _users.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final user = _users[index];
final userObj = user['user'] ?? {}; // Descope struct structure might vary
// Based on Descope API, user root might have fields directly or inside 'user'
// Go SDK SearchAll returns UserResponse struct.
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
final status = user['status'] ?? "unknown";
final isEnabled = status == "enabled" || status == "active";
return ListTile(
leading: CircleAvatar(
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
),
title: Text(name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
tooltip: "Edit User",
onPressed: () => _editUser(user),
),
IconButton(
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green),
tooltip: isEnabled ? "Disable User" : "Enable User",
onPressed: () => _toggleStatus(loginId, status),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: "Delete User",
onPressed: () => _deleteUser(loginId),
),
],
),
);
},
),
),
],
);
}
Widget _buildCreateUserTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isLoading) const LinearProgressIndicator(),
const SizedBox(height: 20),
TextFormField(
controller: _createLoginIdController,
decoration: const InputDecoration(
labelText: "Login ID (Required)",
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _createNameController,
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)),
),
const SizedBox(height: 16),
TextFormField(
controller: _createEmailController,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
),
const SizedBox(height: 16),
TextFormField(
controller: _createPhoneController,
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _createUserSubmit,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text("Create User"),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
class ApproveQrScreen extends StatefulWidget {
final String? pendingRef;
const ApproveQrScreen({super.key, this.pendingRef});
@override
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
}
class _ApproveQrScreenState extends State<ApproveQrScreen> {
bool _isLoading = false;
String? _message;
bool _success = false;
Future<void> _handleApprove() async {
if (widget.pendingRef == null) return;
final session = Descope.sessionManager.session;
if (session == null || session.refreshToken.isExpired) {
setState(() => _message = "Please log in on your phone first.");
context.go('/signin'); // Redirect to login
return;
}
setState(() {
_isLoading = true;
_message = null;
});
// jwt 유효성 확인
try {
await AuthProxyService.approveQrLogin(
widget.pendingRef!,
session.sessionToken.jwt,
);
setState(() {
_success = true;
_message = "Login Approved! Your browser should now be logged in.";
});
// Automatically go to dashboard after a short delay
Future.delayed(const Duration(seconds: 1), () {
if (mounted) context.go('/');
});
} catch (e) {
setState(() => _message = "Error: $e");
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false;
return Scaffold(
appBar: AppBar(title: const Text("QR Login Approval")),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
const SizedBox(height: 24),
const Text(
"Web Login Request",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
"A computer is trying to log in using this QR code.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 40),
if (_message != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
_message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
if (!_success)
FilledButton.icon(
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
icon: const Icon(Icons.check_circle),
label: const Text("Approve Login"),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(60),
backgroundColor: Colors.blue,
),
),
if (!isLoggedIn && !_success)
Padding(
padding: const EdgeInsets.only(top: 16),
child: TextButton(
onPressed: () => context.go('/signin'),
child: const Text("Login on this device first"),
),
),
if (_success)
FilledButton(
onPressed: () => context.go('/'),
child: const Text("Go to My Dashboard"),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/services/auth_proxy_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final TextEditingController _loginIdController = TextEditingController();
bool _isLoading = false;
Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim();
if (input.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해주세요.");
return;
}
String loginId = input;
if (!input.contains('@')) {
// Format phone number if it's not an email
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(loginId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
_showError("전송에 실패했습니다: ${e.toString()}");
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("비밀번호 재설정"),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"비밀번호를 잊으셨나요?",
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 40),
TextField(
controller: _loginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordReset(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handlePasswordReset,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("재설정 링크 전송"),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,730 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../core/services/audit_service.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/notifiers/auth_notifier.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
const LoginScreen({super.key, this.verificationToken});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl;
// QR Login Variables
String? _qrImageBase64;
String? _qrPendingRef;
bool _isQrLoading = false;
Timer? _qrPollingTimer;
int _qrRemainingSeconds = 0;
Timer? _qrCountdownTimer;
@override
void initState() {
super.initState();
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
// Check for tokens (Path Parameter or Legacy Query Parameter)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.verificationToken != null) {
_verifyToken(widget.verificationToken!);
} else {
final uri = Uri.base;
if (uri.queryParameters.containsKey('t')) {
_verifyToken(uri.queryParameters['t']!);
}
}
final uri = Uri.base;
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
}
});
}
// Helper to decode JWT and get loginId
String _getLoginIdFromJwt(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) return 'User';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = json.decode(payload);
// Descope tokens usually have 'name', 'email', or 'sub'
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
} catch (e) {
debugPrint("[JWT] Decode error: $e");
return 'User';
}
}
// Helper to decode JWT and get User ID (sub claim)
String _getUserIdFromJwt(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) return 'unknown';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = json.decode(payload) as Map<String, dynamic>;
return data['sub'] as String? ?? 'unknown';
} catch (e) {
debugPrint("[JWT] Could not extract User ID (sub): $e");
return 'unknown';
}
}
void _handleTabSelection() {
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
if (_tabController.index == 2 && _qrPendingRef == null) {
_startQrFlow();
} else if (_tabController.index != 2) {
_stopQrPolling();
}
}
Future<void> _startQrFlow() async {
if (_isQrLoading) return;
setState(() {
_isQrLoading = true;
_qrImageBase64 = null;
_qrRemainingSeconds = 0;
});
try {
final res = await AuthProxyService.initQrLogin();
if (mounted) {
setState(() {
_qrImageBase64 = res['qrCode'];
_qrPendingRef = res['pendingRef'];
_qrRemainingSeconds = res['expiresIn'] ?? 300;
_isQrLoading = false;
});
_startQrPolling();
_startCountdown();
}
} catch (e) {
_showError("Failed to init QR: $e");
if (mounted) setState(() => _isQrLoading = false);
}
}
void _startCountdown() {
_qrCountdownTimer?.cancel();
_qrCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
if (_qrRemainingSeconds <= 0) _stopQrPolling();
return;
}
setState(() {
_qrRemainingSeconds--;
});
});
}
void _startQrPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
timer.cancel();
_qrCountdownTimer?.cancel();
final jwt = res['sessionJwt'];
final displayName = _getLoginIdFromJwt(jwt);
// Create User & Session for Descope SDK
final dummyUser = DescopeUser(
'unknown', // userId
[], // loginIds
0, // createdAt
displayName, // name
null, // picture (Uri?)
'', // email
false, // isVerifiedEmail
'', // phone
false, // isVerifiedPhone
{}, // customAttributes
'', // givenName
'', // middleName
'', // familyName
false, // hasPassword
'enabled', // status
[], // roleNames
[], // ssoAppIds
[], // oauthProviders (List<String>)
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
_onLoginSuccess(jwt);
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
});
}
void _stopQrPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer = null;
_qrCountdownTimer?.cancel();
_qrCountdownTimer = null;
_qrPendingRef = null;
}
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
}
Future<void> _verifyToken(String token) async {
debugPrint("[Auth] Starting verification for token: $token");
try {
// Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink(token);
final jwt = res['token'];
debugPrint("[Auth] Verification successful for token: $token");
if (jwt != null && mounted) {
final displayName = _getLoginIdFromJwt(jwt);
// Create User & Session for Descope SDK to log in this tab
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
// Refresh Token을 LocalStorage에 저장
Descope.sessionManager.manageSession(session);
// Notify and Go to Dashboard
_onLoginSuccess(jwt);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
if (mounted) {
_showError("Verification failed: $e");
}
}
}
@override
void dispose() {
_stopQrPolling();
_tabController.dispose();
_linkIdController.dispose();
_passwordLoginIdController.dispose();
_passwordController.dispose();
super.dispose();
}
// 이메일/비밀번호 로그인 처리
Future<void> _handlePasswordLogin() async {
final input = _passwordLoginIdController.text.trim();
final password = _passwordController.text.trim();
if (input.isEmpty || password.isEmpty) {
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
return;
}
String loginId = input;
if (!input.contains('@')) {
// Format phone number if it's not an email
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
// 로딩 인디케이터 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
final res = await AuthProxyService.loginWithPassword(loginId, password);
final jwt = res['sessionJwt'];
if (jwt != null && mounted) {
Navigator.of(context).pop(); // 로딩 닫기
_onLoginSuccess(jwt);
}
} catch (e) {
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
}
}
}
// 로그인 링크 전송 처리
Future<void> _handleLinkLogin() async {
final input = _linkIdController.text.trim();
if (input.isEmpty) return;
String loginId = input;
if (!input.contains('@')) {
// Format phone number if it's not an email
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
debugPrint("[Auth] Initiating Enchanted Link for: $loginId");
// 링크 전송 전 사용자 존재 여부 체크 (백엔드에서 이미 처리하지만 에러 핸들링을 위해)
try {
await _startEnchantedFlow(loginId, isEmail: input.contains('@'));
} catch (e) {
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("오류: $e");
}
}
}
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail}) async {
try {
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
// 1. Init via Backend API
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
final pendingRef = initResponse['pendingRef'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef");
if (mounted) {
Navigator.of(context).pop(); // Close Loading
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEmail
? "입력하신 이메일로 로그인 링크를 보냈습니다."
: "입력하신 번호로 로그인 링크를 보냈습니다."),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
TextButton(
onPressed: () {
debugPrint("[Auth] Polling canceled by user");
Navigator.of(context).pop();
},
child: const Text("취소")
)
],
),
),
);
// 2. Poll Backend manually
_pollForSession(pendingRef);
}
} catch (e) {
debugPrint("[Auth] Initialization failed: $e");
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("전송 실패: $e");
}
}
}
Future<void> _pollForSession(String pendingRef) async {
int attempts = 0;
const maxAttempts = 60; // 2 minutes
debugPrint("[Auth] Starting poll for ref: $pendingRef");
while (attempts < maxAttempts && mounted) {
await Future.delayed(const Duration(seconds: 2));
attempts++;
try {
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
if (result['status'] == 'ok') {
final jwt = result['sessionJwt'];
if (jwt != null) {
debugPrint("[Auth] Polling SUCCESS. Token received.");
final displayName = _getLoginIdFromJwt(jwt);
// Descope SDK 세션 강제 주입
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
if (mounted) {
Navigator.of(context).pop(); // Close Polling Dialog
_onLoginSuccess(jwt);
}
return;
}
}
} catch (e) {
debugPrint("[Auth] Polling error (attempt $attempts): $e");
}
}
if (mounted) {
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop(); // Close Polling Dialog
_showError("Login timed out.");
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
try {
AuthProxyService.logError(message);
} catch (e) {
// ignore
}
}
void _logTokenDetails(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) return;
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
final payloadJson = utf8.decode(decodedPayload);
final data = json.decode(payloadJson) as Map<String, dynamic>;
final accessExpValue = data['exp'] as num?;
final accessExp = accessExpValue != null
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
: 'N/A';
final refreshExp = data['rexp'] ?? 'N/A';
debugPrint("""
[Auth] Session Token Details ---
- Access Token Expires: $accessExp
- Refresh Token Expires: $refreshExp
""");
} catch (e) {
debugPrint("[Auth] Failed to decode or log token details: $e");
}
}
void _onLoginSuccess(String token) async {
if (!mounted) return;
_logTokenDetails(token);
final userId = _getUserIdFromJwt(token);
// [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트
try {
// 임시 세션 생성 (API 호출을 위해)
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
Descope.sessionManager.manageSession(tempSession);
// 백엔드 GetMe 호출 (프로필 노티파이어 사용)
final profile = await ref.read(profileProvider.notifier).loadProfile();
if (profile != null) {
// 실제 정보로 세션 유저 정보 교체
final realUser = DescopeUser(
userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [],
);
final realSession = DescopeSession.fromJwt(token, token, realUser);
Descope.sessionManager.manageSession(realSession);
}
} catch (e) {
debugPrint("[Auth] Failed to pre-fetch profile: $e");
}
// Record Audit Log
AuditService.logEvent(
userId: userId,
eventType: "LOGIN_SUCCESS",
status: "SUCCESS",
details: "User logged in via Baron SSO",
);
// 1. Handle Popup Flow
if (WebAuthIntegration.isPopup()) {
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
WebAuthIntegration.sendLoginSuccess(token);
} else {
// 2. Handle Redirect Flow
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
final target = "$_redirectUrl?token=$token";
launchUrlString(target, webOnlyWindowName: '_self');
return;
}
}
// 3. Standalone mode / Fallback
debugPrint("[Auth] Login success. Navigating to root.");
AuthNotifier.instance.notify();
if (mounted) {
context.go('/');
}
}
// [New] 미등록 회원 안내 팝업
void _showUnregisteredDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("미등록 회원"),
content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("취소"),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
context.push('/signup');
},
child: const Text("회원가입 하기"),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"Baron SSO",
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: "비밀번호"),
Tab(text: "로그인 링크"),
Tab(text: "QR 코드"),
],
),
const SizedBox(height: 24),
SizedBox(
height: 350,
child: TabBarView(
controller: _tabController,
children: [
// 1. 이메일/비밀번호 로그인 폼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
children: [
TextField(
controller: _passwordLoginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: "비밀번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _handlePasswordLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("로그인"),
),
],
),
),
// 2. 로그인 링크 전송 폼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
children: [
TextField(
controller: _linkIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
hintText: "",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handleLinkLogin(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _handleLinkLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("로그인 링크 전송"),
),
const SizedBox(height: 24),
const Text(
"입력하신 정보로 로그인 링크를 전송합니다.",
style: TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
],
),
),
// 3. QR 로그인 뷰
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_isQrLoading)
const CircularProgressIndicator()
else if (_qrImageBase64 != null)
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: _qrImageBase64!,
version: QrVersions.auto,
size: 200.0,
),
),
const SizedBox(height: 12),
Text(
_qrRemainingSeconds > 0
? "남은 시간: ${_formatTime(_qrRemainingSeconds)}"
: "QR 코드 만료됨",
textAlign: TextAlign.center,
style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
"모바일 앱으로 스캔하세요",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
TextButton(
onPressed: _startQrFlow,
child: const Text("QR 코드 새로고침")
),
],
)
else
const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center),
],
),
],
),
),
const SizedBox(height: 16),
Column(
children: [
TextButton(
onPressed: () => context.push('/forgot-password'),
child: const Text("비밀번호를 잊으셨나요?"),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)),
TextButton(
onPressed: () => context.push('/signup'),
child: const Text("회원가입"),
),
],
),
],
),
],
),
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
class LoginSuccessScreen extends StatelessWidget {
const LoginSuccessScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const SizedBox(height: 24),
Text(
"로그인 완료",
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
"성공적으로 로그인되었습니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 48),
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
FilledButton.icon(
onPressed: () {
context.push('/scan');
},
icon: const Icon(Icons.camera_alt, size: 28),
label: const Text("QR 인증 (카메라 켜기)"),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () {
context.go('/');
},
child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:descope/descope.dart';
import '../../../core/services/auth_proxy_service.dart';
class QRScanScreen extends StatefulWidget {
const QRScanScreen({super.key});
@override
State<QRScanScreen> createState() => _QRScanScreenState();
}
class _QRScanScreenState extends State<QRScanScreen> {
final _log = Logger('QRScanScreen');
final MobileScannerController controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
);
bool _isScanned = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
_isScanned = true;
String qrData = barcode.rawValue!;
String pendingRef = qrData;
// URL 형식이라면 'ref' 파라미터 추출 시도
if (qrData.startsWith('http')) {
try {
final uri = Uri.parse(qrData);
if (uri.queryParameters.containsKey('ref')) {
pendingRef = uri.queryParameters['ref']!;
}
} catch (e) {
_log.warning('Failed to parse QR URL: $qrData', e);
}
}
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
final sessionToken = Descope.sessionManager.session?.sessionToken.jwt;
if (sessionToken == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red),
);
context.pop();
}
return;
}
try {
// Call backend API to approve login with clean ref
await AuthProxyService.approveQrLogin(pendingRef, sessionToken);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('로그인 승인 완료!'),
backgroundColor: Colors.green,
),
);
// Wait a bit and go back
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) context.pop();
}
} catch (e) {
_log.severe("QR Approval Failed", e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('승인 실패: $e'), backgroundColor: Colors.red),
);
// Allow rescanning after a delay
await Future.delayed(const Duration(seconds: 2));
_isScanned = false;
}
}
break;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: MobileScanner(
controller: controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 50),
const SizedBox(height: 10),
Text('Camera Error: ${error.errorCode}'),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/auth_proxy_service.dart';
class ResetPasswordScreen extends StatefulWidget {
final String? loginId; // Now receiving loginId
const ResetPasswordScreen({super.key, this.loginId});
@override
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
String? _token;
bool _isPasswordObscured = true;
bool _isConfirmPasswordObscured = true;
Map<String, dynamic>? _policy;
bool _isPolicyLoading = false;
@override
void initState() {
super.initState();
// 1. Get loginId from GoRouter state if available
_loginId = widget.loginId;
// 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
_loadPolicy();
}
Future<void> _loadPolicy() async {
setState(() {
_isPolicyLoading = true;
});
try {
final policy = await AuthProxyService.fetchPasswordPolicy();
if (mounted) {
setState(() {
_policy = policy;
});
}
} catch (_) {
// 실패해도 기본 검증 로직 사용
} finally {
if (mounted) {
setState(() {
_isPolicyLoading = false;
});
}
}
}
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)");
return;
}
setState(() => _isLoading = true);
try {
await AuthProxyService.completePasswordReset(
loginId: _loginId,
token: _token,
newPassword: _passwordController.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
backgroundColor: Colors.green,
),
);
context.go('/signin');
}
} catch (e) {
if (mounted) {
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}");
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다...";
}
final minLength = (_policy?['minLength'] as int?) ?? 8;
final requiresLower = _policy?['lowercase'] ?? true;
final requiresUpper = _policy?['uppercase'] ?? true;
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 ${minLength}자 이상"];
if (requiresLower) parts.add("소문자 1개 이상");
if (requiresUpper) parts.add("대문자 1개 이상");
if (requiresNumber) parts.add("숫자 1개 이상");
if (requiresSymbol) parts.add("특수문자 1개 이상");
return parts.join(", ");
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("새 비밀번호 설정"),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
? _buildInvalidTokenView()
: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"새로운 비밀번호 설정",
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
_buildPolicyDescription(),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 40),
TextFormField(
controller: _passwordController,
obscureText: _isPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호",
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
_isPasswordObscured = !_isPasswordObscured;
});
},
),
),
validator: (value) {
final val = value ?? "";
if (val.isEmpty) {
return '비밀번호를 입력해주세요.';
}
final minLength = (_policy?['minLength'] as int?) ?? 8;
if (val.length < minLength) {
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
}
if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) {
return '최소 1개 이상의 소문자를 포함해야 합니다.';
}
if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) {
return '최소 1개 이상의 대문자를 포함해야 합니다.';
}
if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) {
return '최소 1개 이상의 숫자를 포함해야 합니다.';
}
if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) {
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호 확인",
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
});
},
),
),
validator: (value) {
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다.';
}
return null;
},
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handlePasswordReset,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("비밀번호 변경"),
),
],
),
),
),
),
);
}
Widget _buildInvalidTokenView() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 60),
SizedBox(height: 16),
Text(
"유효하지 않은 링크입니다.",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.",
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,970 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/auth_proxy_service.dart';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
int _currentStep = 1;
// Controllers
final _emailController = TextEditingController();
final _emailCodeController = TextEditingController();
final _phoneController = TextEditingController();
final _phoneCodeController = TextEditingController();
final _nameController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _deptController = TextEditingController();
// State
bool _isEmailVerified = false;
bool _isPhoneVerified = false;
String _affiliationType = 'GENERAL';
String? _companyCode;
bool _isAffiliateEmail = false; // 가족사 이메일 여부
bool _termsAccepted = false;
bool _privacyAccepted = false;
bool _isLoading = false;
Map<String, dynamic>? _policy;
bool _isPolicyLoading = false;
// Inline Errors
String? _emailError;
String? _phoneError;
String? _passwordError;
String? _confirmPasswordError;
// Timers
Timer? _emailTimer;
int _emailSeconds = 0;
Timer? _phoneTimer;
int _phoneSeconds = 0;
// 가족사 도메인 맵
final Map<String, String> _affiliateDomains = {
'hanmaceng.co.kr': 'HANMAC',
'samaneng.com': 'SAMAN',
'jangheon.co.kr': 'JANGHEON',
'hallasanup.com': 'HALLA',
'pre-cast.co.kr': 'PTC',
'baroncs.co.kr': 'BARON',
};
@override
void initState() {
super.initState();
_loadPolicy();
}
Future<void> _loadPolicy() async {
setState(() => _isPolicyLoading = true);
try {
final policy = await AuthProxyService.fetchPasswordPolicy();
if (mounted) setState(() => _policy = policy);
} catch (_) {
// Ignore errors, will use defaults
} finally {
if (mounted) setState(() => _isPolicyLoading = false);
}
}
@override
void dispose() {
_emailTimer?.cancel();
_phoneTimer?.cancel();
_emailController.dispose();
_emailCodeController.dispose();
_phoneController.dispose();
_phoneCodeController.dispose();
_nameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_deptController.dispose();
super.dispose();
}
// 이메일 입력 시 도메인 체크 로직
void _checkEmailAffiliation(String email) {
if (!email.contains('@')) {
if (_isAffiliateEmail) {
setState(() {
_isAffiliateEmail = false;
_affiliationType = 'GENERAL';
_companyCode = null;
});
}
return;
}
final domain = email.split('@').last.toLowerCase();
if (_affiliateDomains.containsKey(domain)) {
setState(() {
_isAffiliateEmail = true;
_affiliationType = 'AFFILIATE';
_companyCode = _affiliateDomains[domain];
});
} else {
if (_isAffiliateEmail) {
setState(() {
_isAffiliateEmail = false;
_affiliationType = 'GENERAL';
_companyCode = null;
});
}
}
}
void _startTimer(String type) {
if (type == 'email') {
_emailSeconds = 300;
_emailTimer?.cancel();
_emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
setState(() {
if (_emailSeconds > 0) _emailSeconds--;
else timer.cancel();
});
});
} else {
_phoneSeconds = 180;
_phoneTimer?.cancel();
_phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
setState(() {
if (_phoneSeconds > 0) _phoneSeconds--;
else timer.cancel();
});
});
}
}
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _sendEmailCode() async {
final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = '유효한 이메일 형식이 아닙니다.');
return;
}
setState(() { _isLoading = true; _emailError = null; });
try {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(() => _emailError = '이미 가입된 이메일입니다.');
return;
}
await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email');
} catch (e) {
setState(() => _emailError = '발송 실패: $e');
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _verifyEmailCode() async {
final code = _emailCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
if (success) {
setState(() {
_isEmailVerified = true;
_emailTimer?.cancel();
_emailSeconds = 0;
_emailError = null;
});
} else {
setState(() => _emailError = '인증코드가 일치하지 않습니다.');
}
} catch (e) {
setState(() => _emailError = '인증 실패: $e');
}
}
Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; });
try {
await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone');
} catch (e) {
setState(() => _phoneError = '발송 실패: $e');
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _verifyPhoneCode() async {
final code = _phoneCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
if (success) {
setState(() {
_isPhoneVerified = true;
_phoneTimer?.cancel();
_phoneSeconds = 0;
_phoneError = null;
});
} else {
setState(() => _phoneError = '인증코드가 일치하지 않습니다.');
}
} catch (e) {
setState(() => _phoneError = '인증 실패: $e');
}
}
Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.');
return;
}
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_passwordError = null;
});
try {
await AuthProxyService.signup(
email: _emailController.text.trim(),
password: _passwordController.text,
name: _nameController.text.trim(),
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
termsAccepted: true,
);
if (mounted) _showSuccessDialog();
} catch (e) {
String eStr = e.toString().toLowerCase();
setState(() {
if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.';
else _passwordError = '가입 실패: $e';
});
} finally {
setState(() => _isLoading = false);
}
}
void _showSuccessDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('회원가입 완료'),
content: const Text('성공적으로 가입되었습니다.'),
actions: [TextButton(onPressed: () => context.go('/signin'), child: const Text('로그인하기'))],
),
);
}
// --- UI Components ---
Widget _buildStepIndicator() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
children: [
_stepCircle(1, '약관동의'),
_stepLine(1),
_stepCircle(2, '본인인증'),
_stepLine(2),
_stepCircle(3, '정보입력'),
_stepLine(3),
_stepCircle(4, '비밀번호'),
],
),
);
}
Widget _stepCircle(int step, String label) {
bool isDone = _currentStep > step;
bool isCurrent = _currentStep == step;
return Column(
children: [
CircleAvatar(
radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
],
);
}
Widget _stepLine(int afterStep) {
return Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2),
height: 1.5,
color: _currentStep > afterStep ? Colors.green : Colors.grey[300],
),
);
}
Widget _buildStepAgreement() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('서비스 이용을 위해\n약관에 동의해주세요',
style: GoogleFonts.outfit(
fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)),
const SizedBox(height: 24),
// 모두 동의 버튼
Container(
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[200]!),
),
child: CheckboxListTile(
title: const Text('모두 동의합니다',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
value: _termsAccepted && _privacyAccepted,
onChanged: (val) {
setState(() {
_termsAccepted = val!;
_privacyAccepted = val;
});
},
controlAffinity: ListTileControlAffinity.leading,
activeColor: Colors.black,
),
),
const SizedBox(height: 16),
_agreementSection(
title: '바론 소프트웨어 이용약관 (필수)',
content: _tosText,
value: _termsAccepted,
onChanged: (val) => setState(() => _termsAccepted = val!),
),
const SizedBox(height: 16),
_agreementSection(
title: '개인정보 수집 및 이용 동의 (필수)',
content: _privacyText,
value: _privacyAccepted,
onChanged: (val) => setState(() => _privacyAccepted = val!),
),
],
);
}
Widget _agreementSection({
required String title,
required String content,
required bool value,
required ValueChanged<bool?> onChanged,
}) {
return Column(
children: [
CheckboxListTile(
title: Text(title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
activeColor: Colors.black,
),
Container(
height: 120,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(4),
),
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
),
),
),
],
);
}
static const String _tosText = """
바론 소프트웨어 이용약관
제1장 총칙
제1조 (목적)
이 약관은 바론컨설턴트(이하 "회사"라 합니다)가 제공하는 바론소프트웨어(이하 "서비스"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.
제2조 (용어의 정의)
① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:
- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.
- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.
- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.
- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.
제3조 (약관의 효력 및 변경)
① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.
제4조 (약관 외 준칙)
본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.
제2장 서비스 이용계약
제5조 (이용계약의 성립)
이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.
제6조 (이용계약의 유보와 거절)
① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우
제7조 (계약사항의 변경)
회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.
제3장 개인정보 보호
제8조 (개인정보 보호의 원칙)
① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.
제9조 (개인정보처리방침 준수)
① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.
제10조 (14세 미만 아동의 개인정보 보호)
① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.
제4장 서비스 제공 및 이용
제11조 (서비스 제공)
회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.
제12조 (서비스의 변경 및 중단)
회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.
제5장 정보 제공 및 광고
제13조 (정보 제공 및 광고)
① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.
제6장 게시물 관리
제14조 (게시물의 관리)
회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.
제15조 (게시물의 저작권)
게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.
제7장 계약 해지 및 이용 제한
제16조 (계약 해지)
회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.
제17조 (이용 제한)
회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.
제8장 손해 배상 및 면책 조항
제18조 (손해 배상)
회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.
제19조 (면책 조항)
회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.
제9장 유료 서비스
20조 (유료 서비스의 이용)
① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.
제21조(환불 정책)
① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.
제22조 (유료 서비스의 중지 및 해지)
① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.
제10장 양도 금지
제23조 (양도 금지)
회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.
제11장 관할 법원
제24조 (분쟁 해결)
서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.
제25조 (관할 법원)
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""";
static const String _privacyText = """
개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침
제1조 (목적)
바론컨설턴트(이하 "회사")는 바론서비스(이하 "서비스")를 이용하는 고객(이하 "이용자")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.
제2조 (개인정보의 처리목적)
회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.
- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락
- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리
- 제품소개서 다운로드: 설명자료 전달
- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집
- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공
- 보안가이드 제공: 안내자료 전달
- 기술지원 문의: 서비스 사용 지원
- 서비스 개선 의견 접수: 서비스 품질 개선
- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송
제3조 (개인정보의 처리 및 보유 기간)
① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.
② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:
- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지
- 홍보, 상담, 계약용 개인정보: 2년
제4조 (개인정보의 제3자 제공)
① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.
② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:
- 제공받는 자: 수사기관 및 유관기관, 피신고업체
- 이용 목적: 개인정보 침해 민원 처리
- 제공하는 개인정보 항목: 성명, 연락처, 이메일
- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기
제5조 (개인정보 처리 위탁)
① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.
② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.
제6조 (정보주체의 권리·의무 및 행사 방법)
① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.
② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:
- 서면: 회사 주소로 서면 제출
- 전자우편: 회사 이메일로 요청
- 모사전송(FAX): 회사 FAX로 요청
③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.
④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.
⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.
⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.
제7조 (처리하는 개인정보의 항목)
회사는 다음의 개인정보 항목을 처리합니다:
- 수집 항목:
- 필수 항목: 성명, 휴대전화번호, 이메일
- 선택 항목: 회사전화번호, 문의사항
- 수집 방법:
- 홈페이지, 전화, 이메일을 통해 수집
제8조 (개인정보의 파기)
① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.
② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.
③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:
- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.
- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.
제9조 (개인정보의 안전성 확보 조치)
회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:
- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육
- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치
- 물리적 조치: 전산실 및 자료보관실 접근 통제
제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)
회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.
제11조 (개인정보 보호책임자)
회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.
개인정보 보호책임자:
- 성명: 염승호
- 직책: 수석연구원
- 연락처: 02-2141-7448
- 팩스번호: 02-2141-7599
- 이메일: b23008@baroncs.co.kr
제12조 (개인정보 열람청구)
정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.
개인정보 열람청구 접수·처리 부서:
- 부서명: 총괄기획실
- 담당자: 권혁진
- 연락처: 02-2141-7465
- 팩스번호: 02-2141-7599
- 이메일: baroncs@baroncs.co.kr
제13조 (권익침해 구제방법)
정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.
- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)
- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)
- 대검찰청: (국번없이) 1301 (www.spo.go.kr)
- 경찰청: (국번없이) 182 (www.police.go.kr)
제14조 (개인정보 처리방침의 변경)
본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.
부칙
제1조 (시행일자)
이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.
제2조 (개정 및 고지의 의무)
회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.
제3조 (유효성)
본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.
제4조 (변경 통지의 방법)
회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:
- 서비스 초기화면 또는 팝업 공지
- 이메일 발송
- 회사 홈페이지 공지사항
제5조 (비회원의 개인정보 보호)
회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.
제6조 (14세 미만 아동의 개인정보 보호)
회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.
제7조 (개인정보의 국외 이전)
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""";
Widget _buildStepAuth() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('본인 확인을 위해\n인증을 진행해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// 가족사 이메일 안내 문구
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.blue),
SizedBox(width: 8),
Expanded(
child: Text(
'가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
),
),
],
),
),
const SizedBox(height: 24),
Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
controller: _emailController,
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
decoration: InputDecoration(
labelText: '이메일 주소',
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
),
readOnly: _isEmailVerified,
),
),
const SizedBox(width: 8),
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'),
),
),
],
),
if (_emailSeconds > 0 && !_isEmailVerified) ...[
const SizedBox(height: 8),
TextFormField(
controller: _emailCodeController,
decoration: InputDecoration(
labelText: '인증코드 6자리',
suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
),
],
if (_isEmailVerified) const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 24),
Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
controller: _phoneController,
decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError),
readOnly: _isPhoneVerified,
keyboardType: TextInputType.phone,
),
),
const SizedBox(width: 8),
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'),
),
),
],
),
if (_phoneSeconds > 0 && !_isPhoneVerified) ...[
const SizedBox(height: 8),
TextFormField(
controller: _phoneCodeController,
decoration: InputDecoration(
labelText: '인증코드 6자리',
suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
),
],
if (_isPhoneVerified) const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
),
],
);
}
Widget _buildStepInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('회원님의\n소속 정보를 알려주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
// 소속 유형 선택 (가족사 메일일 경우 비활성화)
AbsorbPointer(
absorbing: _isAffiliateEmail,
child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>(
value: _affiliationType,
decoration: InputDecoration(
labelText: '소속 유형',
border: const OutlineInputBorder(),
helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null,
),
items: const [
DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')),
DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')),
],
onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }),
),
),
),
const SizedBox(height: 16),
// 가족사 선택 (가족사 메일일 경우 비활성화)
if (_affiliationType == 'AFFILIATE') ...[
AbsorbPointer(
absorbing: _isAffiliateEmail,
child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>(
value: _companyCode,
decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 'HANMAC', child: Text('한맥')),
DropdownMenuItem(value: 'SAMAN', child: Text('삼안')),
DropdownMenuItem(value: 'PTC', child: Text('PTC')),
DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')),
DropdownMenuItem(value: 'BARON', child: Text('바론')),
DropdownMenuItem(value: 'HALLA', child: Text('한라')),
],
onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val),
),
),
),
const SizedBox(height: 16),
],
TextFormField(
controller: _deptController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)',
border: const OutlineInputBorder()
),
),
],
);
}
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다...";
}
final minLength = (_policy?['minLength'] as int?) ?? 8;
final requiresLower = _policy?['lowercase'] ?? true;
final requiresUpper = _policy?['uppercase'] ?? true;
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 $minLength자 이상"];
if (requiresUpper) parts.add("대문자");
if (requiresLower) parts.add("소문자");
if (requiresNumber) parts.add("숫자");
if (requiresSymbol) parts.add("특수문자");
return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다.";
}
Widget _buildStepPassword() {
String p = _passwordController.text;
// Default Policy Fallback
final minLength = (_policy?['minLength'] as int?) ?? 8;
final requiresLower = _policy?['lowercase'] ?? true;
final requiresUpper = _policy?['uppercase'] ?? true;
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
bool hasLength = p.length >= minLength;
bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]'));
bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]'));
bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]'));
bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('마지막으로\n비밀번호를 설정해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// 비밀번호 정책 안내 박스
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
child: Row(
children: [
const Icon(Icons.security, size: 18, color: Colors.blue),
const SizedBox(width: 10),
Expanded(
child: Text(
_buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
),
),
],
),
),
const SizedBox(height: 24),
TextFormField(
controller: _passwordController,
obscureText: true,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: '비밀번호',
border: const OutlineInputBorder(),
errorText: _passwordError,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 10,
children: [
_cryptoCheck('$minLength자 이상', hasLength),
if (requiresUpper) _cryptoCheck('대문자', hasUpper),
if (requiresLower) _cryptoCheck('소문자', hasLower),
if (requiresNumber) _cryptoCheck('숫자', hasDigit),
if (requiresSymbol) _cryptoCheck('특수문자', hasSpecial),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
onChanged: (val) {
setState(() {
_confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null;
});
},
decoration: InputDecoration(
labelText: '비밀번호 확인',
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
),
],
);
}
Widget _cryptoCheck(String label, bool isValid) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
],
);
}
@override
Widget build(BuildContext context) {
bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') {
canGoNext = nameOk;
} else {
// AFFILIATE 필수: 이름 + 가족사 선택 + 부서명
final companyOk = _companyCode != null;
final deptOk = _deptController.text.trim().isNotEmpty;
canGoNext = nameOk && companyOk && deptOk;
}
}
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('회원가입', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _buildStepIndicator(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
if (_currentStep > 1) ...[
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
child: const Text('이전', style: TextStyle(color: Colors.black)),
),
),
const SizedBox(width: 12),
],
Expanded(
child: FilledButton(
onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(_currentStep < 4 ? '다음 단계' : '가입 완료'),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/notifiers/auth_notifier.dart';
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
Future<void> _logout(BuildContext context) async {
// ignore: use_build_context_synchronously
Descope.sessionManager.clearSession();
AuthNotifier.instance.notify();
}
void _onScanQR(BuildContext context) {
context.push('/scan');
}
@override
Widget build(BuildContext context) {
final user = Descope.sessionManager.session?.user;
final userName = user?.name ?? user?.email ?? user?.phone ?? 'User';
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: Text('Baron SSO', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _logout(context),
tooltip: 'Sign Out',
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const SizedBox(height: 24),
Text(
'로그인 성공!',
style: GoogleFonts.notoSans(
fontSize: 28,
fontWeight: FontWeight.bold,
color: const Color(0xFF1A1F2C),
),
),
const SizedBox(height: 8),
Text(
'반갑습니다, $userName님',
style: GoogleFonts.notoSans(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 48),
// QR Camera Button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () => _onScanQR(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A1F2C),
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code_scanner, size: 28),
const SizedBox(width: 12),
Text(
'QR 스캔하기',
style: GoogleFonts.notoSans(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(width: 40), // Icon size(28) + Spacing(12) to balance the centering
],
),
),
),
const SizedBox(height: 16),
const Text(
'PC 화면의 QR 코드를 스캔하여 로그인하세요.',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 32),
// My Page Button
OutlinedButton.icon(
onPressed: () => context.push('/profile'),
icon: const Icon(Icons.person),
label: const Text('내 정보 보기'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1F2C),
side: const BorderSide(color: Color(0xFF1A1F2C)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,59 @@
class UserProfile {
final String id;
final String email;
final String name;
final String phone;
final String department;
final String affiliationType;
final String companyCode;
UserProfile({
required this.id,
required this.email,
required this.name,
required this.phone,
required this.department,
required this.affiliationType,
required this.companyCode,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'] ?? '',
email: json['email'] ?? '',
name: json['name'] ?? '',
phone: json['phone'] ?? '',
department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
'phone': phone,
'department': department,
'affiliationType': affiliationType,
'companyCode': companyCode,
};
}
UserProfile copyWith({
String? name,
String? phone,
String? department,
}) {
return UserProfile(
id: id,
email: email,
name: name ?? this.name,
phone: phone ?? this.phone,
department: department ?? this.department,
affiliationType: affiliationType,
companyCode: companyCode,
);
}
}

View File

@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/user_profile_model.dart';
import 'package:descope/descope.dart';
class ProfileRepository {
static String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
// Helper to get session token
static Future<String?> _getToken() async {
final session = await Descope.sessionManager.session;
return session?.sessionToken.jwt;
}
Future<UserProfile> getMyProfile() async {
final token = await _getToken();
if (token == null) throw Exception('No active session');
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final response = await http.get(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
return UserProfile.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to load profile: ${response.body}');
}
}
Future<void> updateMyProfile({
required String name,
required String phone,
required String department,
}) async {
final token = await _getToken();
if (token == null) throw Exception('No active session');
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final response = await http.put(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'name': name,
'phone': phone,
'department': department,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to update profile: ${response.body}');
}
}
Future<void> sendUpdateCode(String phone) async {
final token = await _getToken();
if (token == null) throw Exception('No active session');
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'phone': phone}),
);
if (response.statusCode != 200) {
throw Exception('인증번호 전송 실패: ${response.body}');
}
}
Future<void> verifyUpdateCode(String phone, String code) async {
final token = await _getToken();
if (token == null) throw Exception('No active session');
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'phone': phone, 'code': code}),
);
if (response.statusCode != 200) {
throw Exception('인증 실패: ${response.body}');
}
}
}

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/user_profile_model.dart';
import '../../data/repositories/profile_repository.dart';
// 1. Repository Provider
final profileRepositoryProvider = Provider((ref) => ProfileRepository());
// 2. AsyncNotifier implementation (Modern Riverpod)
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
@override
FutureOr<UserProfile?> build() async {
// Initial data fetch
return _fetch();
}
Future<UserProfile?> _fetch() async {
return ref.read(profileRepositoryProvider).getMyProfile();
}
Future<UserProfile?> loadProfile() async {
state = const AsyncValue.loading();
final profile = await _fetch();
state = AsyncValue.data(profile);
return profile;
}
Future<void> updateProfile({
required String name,
required String phone,
required String department,
}) async {
// Show loading state
state = const AsyncValue.loading();
// Perform update and then re-fetch profile
state = await AsyncValue.guard(() async {
await ref.read(profileRepositoryProvider).updateMyProfile(
name: name,
phone: phone,
department: department,
);
return _fetch();
});
}
}
// 3. Provider definition
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
return ProfileNotifier();
});

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/notifiers/profile_notifier.dart';
class EditProfilePage extends ConsumerStatefulWidget {
const EditProfilePage({super.key});
@override
ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _phoneController;
late TextEditingController _codeController;
late TextEditingController _departmentController;
String? _initialPhone;
bool _isPhoneChanged = false;
bool _isPhoneVerified = false;
bool _isCodeSent = false;
bool _isVerifying = false;
@override
void initState() {
super.initState();
final profile = ref.read(profileProvider).value;
_initialPhone = profile?.phone ?? '';
_nameController = TextEditingController(text: profile?.name ?? '');
_phoneController = TextEditingController(text: _initialPhone);
_codeController = TextEditingController();
_departmentController = TextEditingController(text: profile?.department ?? '');
_phoneController.addListener(() {
setState(() {
_isPhoneChanged = _phoneController.text != _initialPhone;
if (_isPhoneChanged) {
_isPhoneVerified = false;
}
});
});
}
@override
void dispose() {
_nameController.dispose();
_phoneController.dispose();
_codeController.dispose();
_departmentController.dispose();
super.dispose();
}
Future<void> _sendCode() async {
final phone = _phoneController.text;
if (phone.isEmpty) return;
setState(() => _isVerifying = true);
try {
await ref.read(profileRepositoryProvider).sendUpdateCode(phone);
setState(() {
_isCodeSent = true;
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('인증번호가 전송되었습니다.')),
);
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('전송 실패: $e')),
);
}
}
}
Future<void> _verifyCode() async {
final phone = _phoneController.text;
final code = _codeController.text;
if (code.isEmpty) return;
setState(() => _isVerifying = true);
try {
await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code);
setState(() {
_isPhoneVerified = true;
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('인증되었습니다.')),
);
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('인증 실패: $e')),
);
}
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_isPhoneChanged && !_isPhoneVerified) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')),
);
return;
}
try {
await ref.read(profileProvider.notifier).updateProfile(
name: _nameController.text,
phone: _phoneController.text,
department: _departmentController.text,
);
if (mounted) {
context.pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('정보가 수정되었습니다.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('수정 실패: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final profileState = ref.watch(profileProvider);
final isUpdating = profileState.isLoading;
return Scaffold(
appBar: AppBar(
title: const Text('내 정보 수정'),
actions: [
TextButton(
onPressed: (isUpdating || (_isPhoneChanged && !_isPhoneVerified)) ? null : _save,
child: const Text('저장'),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: ListView(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) => (value == null || value.isEmpty) ? '이름을 입력해주세요.' : null,
),
const SizedBox(height: 24),
// Phone Number Field
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextFormField(
controller: _phoneController,
decoration: InputDecoration(
labelText: '휴대폰 번호',
hintText: '01012345678',
prefixIcon: const Icon(Icons.phone_android),
suffixIcon: _isPhoneVerified
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
keyboardType: TextInputType.phone,
enabled: !_isPhoneVerified,
),
),
const SizedBox(width: 8),
if (_isPhoneChanged && !_isPhoneVerified)
ElevatedButton(
onPressed: _isVerifying ? null : _sendCode,
child: Text(_isCodeSent ? '재전송' : '인증요청'),
),
],
),
// OTP Code Field
if (_isCodeSent && !_isPhoneVerified) ...[
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextFormField(
controller: _codeController,
decoration: const InputDecoration(
labelText: '인증번호',
hintText: '6자리 입력',
prefixIcon: Icon(Icons.security),
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isVerifying ? null : _verifyCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue[700], foregroundColor: Colors.white),
child: const Text('확인'),
),
],
),
],
if (_isPhoneChanged && !_isPhoneVerified)
const Padding(
padding: EdgeInsets.only(top: 8.0, left: 4.0),
child: Text(
'휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
style: TextStyle(color: Colors.orange, fontSize: 12),
),
),
const SizedBox(height: 24),
TextFormField(
controller: _departmentController,
decoration: const InputDecoration(
labelText: '소속 (부서)',
prefixIcon: Icon(Icons.business),
),
),
const SizedBox(height: 40),
if (isUpdating || _isVerifying)
const Center(child: CircularProgressIndicator()),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/notifiers/profile_notifier.dart';
import '../widgets/profile_info_row.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// profileState is AsyncValue<UserProfile?>
final profileState = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('내 정보'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push('/profile/edit'),
),
],
),
body: profileState.when(
data: (profile) {
if (profile == null) {
return const Center(child: Text('정보를 불러올 수 없습니다.'));
}
return RefreshIndicator(
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Center(
child: CircleAvatar(
radius: 40,
child: Icon(Icons.person, size: 40),
),
),
const SizedBox(height: 24),
ProfileInfoRow(label: '이름', value: profile.name),
ProfileInfoRow(label: '이메일', value: profile.email),
ProfileInfoRow(label: '전화번호', value: profile.phone),
const Divider(height: 32),
ProfileInfoRow(label: '소속', value: profile.department),
ProfileInfoRow(label: '구분', value: profile.affiliationType),
if (profile.companyCode.isNotEmpty)
ProfileInfoRow(label: '회사코드', value: profile.companyCode),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류 발생: $err'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
child: const Text('재시도'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class ProfileInfoRow extends StatelessWidget {
final String label;
final String value;
const ProfileInfoRow({
super.key,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value.isEmpty ? '-' : value,
style: const TextStyle(fontSize: 16),
),
),
],
),
);
}
}

214
userfront/lib/main.dart Normal file
View File

@@ -0,0 +1,214 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'features/auth/presentation/login_screen.dart';
import 'features/auth/presentation/signup_screen.dart';
import 'features/auth/presentation/approve_qr_screen.dart';
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/dashboard/presentation/dashboard_screen.dart';
import 'features/admin/presentation/user_management_screen.dart';
import 'features/profile/presentation/pages/profile_page.dart';
import 'features/profile/presentation/pages/edit_profile_page.dart';
import 'core/services/auth_proxy_service.dart';
import 'core/services/logger_service.dart';
import 'core/notifiers/auth_notifier.dart';
import 'package:logging/logging.dart';
final _log = Logger('Main');
void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
// 1. Global Error Handling
FlutterError.onError = (details) {
FlutterError.presentError(details);
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
// Also send to backend if needed
AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}");
};
PlatformDispatcher.instance.onError = (error, stack) {
_log.severe("PLATFORM_ERROR", error, stack);
AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack");
return true;
};
// .env가 없더라도 초기화 상태를 보장하도록 optional 로딩
try {
await dotenv.load(fileName: ".env", isOptional: true);
} catch (e) {
_log.warning("Warning: .env file load failed: $e");
}
// 0. Initialize Logger
LoggerService.init();
// Initialize Descope (프로젝트 ID가 없으면 경고만 남기고 진행)
final projectId = dotenv.maybeGet('DESCOPE_PROJECT_ID') ?? '';
if (projectId.isEmpty || projectId == 'your-project-id') {
_log.severe("DESCOPE_PROJECT_ID is missing. Descope may not work correctly.");
}
Descope.setup(projectId);
// Load saved session if any
try {
// 저장된 세션 불러옴
await Descope.sessionManager.loadSession();
} catch (e) {
_log.warning("Failed to load session: $e");
}
runApp(const ProviderScope(child: BaronSSOApp()));
}
// Router Configuration
final _routerLogger = Logger('Router');
final _router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true, // Enable diagnostic logs
refreshListenable: AuthNotifier.instance,
routes: [
GoRoute(
path: '/',
builder: (context, state) {
_routerLogger.info("Navigating to root (DashboardScreen)");
return const DashboardScreen();
},
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
routes: [
GoRoute(
path: 'edit',
builder: (context, state) => const EditProfilePage(),
),
],
),
GoRoute(
path: '/signin',
builder: (context, state) {
_routerLogger.info("Navigating to /signin");
return const LoginScreen();
}
),
GoRoute(
path: '/signup',
builder: (context, state) {
_routerLogger.info("Navigating to /signup");
return const SignupScreen();
},
),
GoRoute(
path: '/verify/:token',
builder: (context, state) {
final token = state.pathParameters['token'];
_routerLogger.info("Navigating to /verify with token: $token");
return LoginScreen(verificationToken: token);
},
),
GoRoute(
path: '/forgot-password',
builder: (context, state) {
_routerLogger.info("Navigating to /forgot-password");
return const ForgotPasswordScreen();
},
),
GoRoute(
// Supports both /reset-password and /reset-password?token=...
path: '/reset-password',
builder: (context, state) {
// For deep linking, you might pass the token in the path, e.g., /reset-password/:token
// final token = state.pathParameters['token'];
_routerLogger.info("Navigating to /reset-password");
return const ResetPasswordScreen();
},
),
GoRoute(
path: '/approve',
builder: (context, state) {
final ref = state.uri.queryParameters['ref'];
_routerLogger.info("Navigating to /approve with ref: $ref");
return ApproveQrScreen(pendingRef: ref);
},
),
GoRoute(
path: '/scan',
builder: (context, state) {
_routerLogger.info("Navigating to /scan");
return const QRScanScreen();
},
),
GoRoute(
path: '/admin/users',
builder: (context, state) {
_routerLogger.info("Navigating to /admin/users");
return const UserManagementScreen();
},
),
],
redirect: (context, state) {
final isLoggedIn =
Descope.sessionManager.session?.refreshToken?.isExpired == false;
final path = state.uri.path;
// Public paths that don't require login
final isPublicPath = path == '/signin' ||
path == '/signup' ||
path.startsWith('/verify/') ||
path == '/approve' ||
path == '/forgot-password' ||
path == '/reset-password';
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
// 0. ALWAYS allow public paths to proceed so they can function
if (isPublicPath) {
return null;
}
// If not logged in and trying to access a protected page, redirect to /signin
if (!isLoggedIn) {
_routerLogger.info("Not logged in, redirecting to /signin");
return '/signin';
}
// If logged in and trying to access login page, redirect to root (dashboard)
// This is now implicitly handled by the isPublicPath check, but kept for clarity.
// if (isLoggedIn && path == '/signin') {
// _routerLogger.info("Logged in, redirecting to /");
// return '/';
// }
return null;
},
);
class BaronSSOApp extends StatelessWidget {
const BaronSSOApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Baron SSO',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
brightness: Brightness.light,
),
useMaterial3: true,
textTheme: GoogleFonts.interTextTheme(),
),
routerConfig: _router,
);
}
}

43
userfront/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
# Map ISO8601 time to "YYYY-MM-DD HH:mm:ss" format
map $time_iso8601 $time_custom {
"~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" "$1-$2-$3 $4:$5:$6";
}
# Custom JSON Log Format matching Go slog
log_format json_combined escape=json
'{'
'"time":"$time_custom",'
'"level":"INFO",'
'"msg":"http_access",'
'"svc":"baron-userfront",'
'"status":$status,'
'"method":"$request_method",'
'"path":"$request_uri",'
'"latency":"${request_time}s",'
'"ip":"$remote_addr",'
'"forwarded_for":"$http_x_forwarded_for",'
'"user_agent":"$http_user_agent"'
'}';
server {
listen 5000;
include /etc/nginx/mime.types;
access_log /var/log/nginx/access.log json_combined;
# Backend API Proxy
location /api {
proxy_pass http://baron_backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend Static Files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}

722
userfront/pubspec.lock Normal file
View File

@@ -0,0 +1,722 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "8.4.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cryptography:
dependency: transitive
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
descope:
dependency: "direct main"
description:
name: descope
sha256: da73578c619aefb82411ddca3e61423006a36900ff8cdc4b4ef42c4863f8ad36
url: "https://pub.dev"
source: hosted
version: "0.9.11"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: d4130c4a43e0b13fefc593bc3961f2cb46e30cb79e253d4a526b1b5d24ae1ce4
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
userfront_server_client:
dependency: transitive
description:
name: userfront_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
url: "https://pub.dev"
source: hosted
version: "17.0.1"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59
url: "https://pub.dev"
source: hosted
version: "3.0.3"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
url: "https://pub.dev"
source: hosted
version: "1.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
flutter: ">=3.35.0"

98
userfront/pubspec.yaml Normal file
View File

@@ -0,0 +1,98 @@
name: app.brsw.kr
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.10.4
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^3.0.3
go_router: ^17.0.1
descope: ^0.9.11
http: ^1.6.0
google_fonts: ^6.3.3
flutter_dotenv: ^5.1.0
url_launcher: ^6.3.2
logging: ^1.2.0
logger: ^2.0.0
qr_flutter: ^4.1.0
mobile_scanner: ^6.0.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - .env
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@@ -0,0 +1,19 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:userfront/main.dart' show BaronSSOApp;
void main() {
testWidgets('BaronSSOApp builds', (WidgetTester tester) async {
// runApp에서 ProviderScope로 감싸서 쓰고 있으니 테스트도 동일하게 감쌈
await tester.pumpWidget(const ProviderScope(child: BaronSSOApp()));
await tester.pump(); // 한 프레임 더
});
}

BIN
userfront/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

39
userfront/web/index.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="/">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="userfront">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>userfront</title>
<link rel="manifest" href="manifest.json">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
{
"name": "userfront",
"short_name": "userfront",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}