forked from baron/baron-sso
내정보 페이지 사용성개선, adminFront user 정보 연동.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -14,12 +14,19 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { createUser, type UserCreateRequest } from "../../lib/adminApi";
|
||||
import {
|
||||
createUser,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
} from "../../lib/adminApi";
|
||||
|
||||
function UserCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -39,8 +46,13 @@ function UserCreatePage() {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data: UserCreateResponse) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
if (data.initialPassword) {
|
||||
setGeneratedPassword(data.initialPassword);
|
||||
setCreatedEmail(data.email);
|
||||
return;
|
||||
}
|
||||
navigate("/users");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
@@ -50,9 +62,31 @@ function UserCreatePage() {
|
||||
|
||||
const onSubmit = (data: UserCreateRequest) => {
|
||||
setError(null);
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
if (autoPassword) {
|
||||
mutation.mutate({ ...data, password: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.password) {
|
||||
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
const onCopyPassword = async () => {
|
||||
if (!generatedPassword) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedPassword);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
@@ -74,12 +108,33 @@ function UserCreatePage() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{generatedPassword && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
||||
<CardDescription>
|
||||
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
|
||||
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => navigate("/users")}>목록으로 이동</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>계정 정보</CardTitle>
|
||||
<CardDescription>
|
||||
새로운 사용자를 시스템에 등록합니다.
|
||||
</CardDescription>
|
||||
<CardDescription>새로운 사용자를 시스템에 등록합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
@@ -102,22 +157,29 @@ function UserCreatePage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPassword}
|
||||
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||
/>
|
||||
자동 생성
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
{...register("password", {
|
||||
required: "비밀번호는 필수입니다.",
|
||||
minLength: { value: 6, message: "6자 이상 입력해주세요." },
|
||||
})}
|
||||
disabled={autoPassword}
|
||||
{...register("password")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
초기 비밀번호를 설정합니다.
|
||||
{autoPassword
|
||||
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
|
||||
: "초기 비밀번호를 직접 설정합니다."}
|
||||
</p>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -203,4 +265,4 @@ function UserCreatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCreatePage;
|
||||
export default UserCreatePage;
|
||||
|
||||
@@ -215,6 +215,7 @@ function UserListPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
aria-label={`사용자 수정: ${user.name}`}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
@@ -224,6 +225,7 @@ function UserListPage() {
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={`사용자 삭제: ${user.name}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -182,7 +182,7 @@ export type UserListResponse = {
|
||||
|
||||
export type UserCreateRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
@@ -190,6 +190,10 @@ export type UserCreateRequest = {
|
||||
department?: string;
|
||||
};
|
||||
|
||||
export type UserCreateResponse = UserSummary & {
|
||||
initialPassword?: string;
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
password?: string;
|
||||
name?: string;
|
||||
@@ -215,7 +219,7 @@ export async function fetchUser(userId: string) {
|
||||
}
|
||||
|
||||
export async function createUser(payload: UserCreateRequest) {
|
||||
const { data } = await apiClient.post<UserSummary>(
|
||||
const { data } = await apiClient.post<UserCreateResponse>(
|
||||
"/v1/admin/users",
|
||||
payload,
|
||||
);
|
||||
|
||||
139
adminfront/tests/user-management.spec.ts
Normal file
139
adminfront/tests/user-management.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
type UserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserCreatePayload = {
|
||||
email: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
};
|
||||
|
||||
test("user create and delete flow", async ({ page }) => {
|
||||
const users: UserSummary[] = [];
|
||||
let idSeq = 1;
|
||||
|
||||
await page.route("**/api/v1/admin/users**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
const isCollection = path.endsWith("/api/v1/admin/users");
|
||||
const isItem = path.includes("/api/v1/admin/users/");
|
||||
|
||||
if (request.method() === "GET" && isCollection) {
|
||||
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
|
||||
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||
const offset = Number(url.searchParams.get("offset") ?? "0");
|
||||
const filtered = search
|
||||
? users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(search) ||
|
||||
user.email.toLowerCase().includes(search),
|
||||
)
|
||||
: users;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
limit,
|
||||
offset,
|
||||
total: filtered.length,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST" && isCollection) {
|
||||
const payload = request.postDataJSON() as UserCreatePayload;
|
||||
const now = new Date().toISOString();
|
||||
const user: UserSummary = {
|
||||
id: `user-${idSeq++}`,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
phone: payload.phone,
|
||||
role: payload.role ?? "user",
|
||||
status: "active",
|
||||
companyCode: payload.companyCode,
|
||||
department: payload.department,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
users.unshift(user);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE" && isItem) {
|
||||
const userId = path.split("/").pop();
|
||||
const index = users.findIndex((user) => user.id === userId);
|
||||
if (index === -1) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "User not found" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
users.splice(index, 1);
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Not found" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
|
||||
await page.getByRole("link", { name: "사용자 추가" }).click();
|
||||
await expect(page).toHaveURL(/\/users\/new$/);
|
||||
|
||||
const uniqueEmail = `playwright-${Date.now()}@example.com`;
|
||||
|
||||
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
|
||||
await page.getByLabel("이메일").fill(uniqueEmail);
|
||||
await page.getByLabel("비밀번호").fill("Test1234!");
|
||||
await page.getByLabel("이름").fill("Playwright User");
|
||||
await page.getByLabel("전화번호").fill("010-0000-0000");
|
||||
await page.getByLabel("회사 코드").fill("E2E");
|
||||
await page.getByLabel("부서").fill("QA");
|
||||
await page.getByLabel("역할 (Role)").selectOption("admin");
|
||||
|
||||
await page.getByRole("button", { name: "사용자 생성" }).click();
|
||||
await expect(page).toHaveURL(/\/users$/);
|
||||
|
||||
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
|
||||
await expect(createdRow).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
|
||||
|
||||
await expect(
|
||||
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -99,6 +99,9 @@ func main() {
|
||||
}
|
||||
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||
// -----------------------------------
|
||||
if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
}
|
||||
|
||||
// 2. Initialize DB Connections
|
||||
// ClickHouse
|
||||
@@ -186,7 +189,9 @@ func main() {
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler()
|
||||
tenantHandler := handler.NewTenantHandler(db)
|
||||
userHandler := handler.NewUserHandler(db)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
@@ -398,10 +403,10 @@ func main() {
|
||||
|
||||
// API Group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
|
||||
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
|
||||
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
|
||||
|
||||
|
||||
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
|
||||
Repo: auditRepo,
|
||||
ExcludePaths: map[string]struct{}{
|
||||
@@ -451,6 +456,7 @@ func main() {
|
||||
user.Put("/me", authHandler.UpdateMe)
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
|
||||
@@ -2,12 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -20,11 +17,7 @@ func Run(db *gorm.DB) error {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Seed Initial Admin User
|
||||
if err := seedAdminUser(db); err != nil {
|
||||
return fmt.Errorf("seeding admin failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
|
||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||
return nil
|
||||
}
|
||||
@@ -33,7 +26,6 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||
// Add all domain models here
|
||||
return db.AutoMigrate(
|
||||
&domain.User{},
|
||||
&domain.Tenant{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
@@ -41,44 +33,3 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
|
||||
)
|
||||
}
|
||||
|
||||
func seedAdminUser(db *gorm.DB) error {
|
||||
adminEmail := os.Getenv("ADMIN_EMAIL")
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
|
||||
if adminEmail == "" || adminPassword == "" {
|
||||
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin seeding.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := db.Unscoped().Where("email = ?", adminEmail).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adminUser := domain.User{
|
||||
Email: adminEmail,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: "System Admin",
|
||||
Role: "admin", // Assuming 'role' field exists or handling via attributes
|
||||
// Add other required fields
|
||||
}
|
||||
|
||||
if err := db.Create(&adminUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("[Bootstrap] Admin user created successfully.")
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
slog.Info("[Bootstrap] Admin user already exists.", "email", adminEmail)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
51
backend/internal/bootstrap/kratos_seed.go
Normal file
51
backend/internal/bootstrap/kratos_seed.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
|
||||
func SeedAdminIdentity(idp domain.IdentityProvider) error {
|
||||
if idp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminEmail == "" || adminPassword == "" {
|
||||
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
|
||||
if adminName == "" {
|
||||
adminName = "System Admin"
|
||||
}
|
||||
|
||||
user := &domain.BrokerUser{
|
||||
Email: adminEmail,
|
||||
Name: adminName,
|
||||
PhoneNumber: "",
|
||||
Attributes: map[string]interface{}{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := idp.CreateUser(user, adminPassword)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
slog.Info("[Bootstrap] Admin identity already exists in IDP", "email", adminEmail)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name())
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -75,6 +76,7 @@ type AuthHandler struct {
|
||||
DescopeClient *client.DescopeClient
|
||||
IdpProvider domain.IdentityProvider
|
||||
AuditRepo domain.AuditRepository
|
||||
Hydra *service.HydraAdminService
|
||||
}
|
||||
|
||||
type signupState struct {
|
||||
@@ -156,6 +158,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
||||
DescopeClient: descopeClient,
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,49 +500,7 @@ func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy {
|
||||
|
||||
// validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다.
|
||||
func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if policy.MinLength > 0 && len(password) < policy.MinLength {
|
||||
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
||||
}
|
||||
|
||||
types := 0
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password)
|
||||
if hasLower {
|
||||
types++
|
||||
}
|
||||
if hasUpper {
|
||||
types++
|
||||
}
|
||||
if hasNumber {
|
||||
types++
|
||||
}
|
||||
if hasSymbol {
|
||||
types++
|
||||
}
|
||||
|
||||
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
||||
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
||||
}
|
||||
|
||||
return nil
|
||||
return utils.ValidatePasswordWithPolicy(policy, password)
|
||||
}
|
||||
|
||||
// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다.
|
||||
@@ -2345,6 +2306,109 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
type linkedRpSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
type linkedRpListResponse struct {
|
||||
Items []linkedRpSummary `json:"items"`
|
||||
}
|
||||
|
||||
type linkedRpRecord struct {
|
||||
linkedRpSummary
|
||||
lastAuth time.Time
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
if h.Hydra == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"})
|
||||
}
|
||||
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
}
|
||||
|
||||
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
records := make(map[string]*linkedRpRecord)
|
||||
for _, session := range sessions {
|
||||
clientID := strings.TrimSpace(session.Client.ClientID)
|
||||
if clientID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(session.Client.ClientName)
|
||||
if name == "" {
|
||||
name = clientID
|
||||
}
|
||||
|
||||
lastAuth := time.Time{}
|
||||
if session.AuthenticatedAt != nil {
|
||||
lastAuth = *session.AuthenticatedAt
|
||||
} else if session.RequestedAt != nil {
|
||||
lastAuth = *session.RequestedAt
|
||||
}
|
||||
|
||||
scopes := session.GrantedScope
|
||||
if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" {
|
||||
scopes = strings.Fields(session.Client.Scope)
|
||||
}
|
||||
|
||||
existing := records[clientID]
|
||||
if existing == nil {
|
||||
records[clientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(session.Client.Metadata),
|
||||
Status: hydraClientStatus(session.Client.Metadata),
|
||||
Scopes: scopes,
|
||||
},
|
||||
lastAuth: lastAuth,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Name == "" {
|
||||
existing.Name = name
|
||||
}
|
||||
if existing.Logo == "" {
|
||||
existing.Logo = extractHydraClientLogo(session.Client.Metadata)
|
||||
}
|
||||
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
||||
if lastAuth.After(existing.lastAuth) {
|
||||
existing.lastAuth = lastAuth
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]*linkedRpRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
ordered = append(ordered, record)
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].lastAuth.After(ordered[j].lastAuth)
|
||||
})
|
||||
|
||||
items := make([]linkedRpSummary, 0, len(ordered))
|
||||
for _, record := range ordered {
|
||||
if !record.lastAuth.IsZero() {
|
||||
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
||||
}
|
||||
items = append(items, record.linkedRpSummary)
|
||||
}
|
||||
|
||||
return c.JSON(linkedRpListResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
@@ -2384,6 +2448,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
return h.getKratosProfileWithCookie(cookie)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
return h.resolveIdentityID(c, token)
|
||||
}
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return "", fmt.Errorf("missing authorization token")
|
||||
}
|
||||
identityID, _, err := h.getKratosIdentityWithCookie(cookie)
|
||||
return identityID, err
|
||||
}
|
||||
|
||||
func isAuthEventType(eventType string) bool {
|
||||
normalized := strings.ToLower(eventType)
|
||||
return strings.Contains(normalized, " /api/v1/auth/")
|
||||
@@ -3345,3 +3422,68 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
|
||||
func hydraClientStatus(metadata map[string]interface{}) string {
|
||||
if metadata == nil {
|
||||
return "active"
|
||||
}
|
||||
if value, ok := metadata["status"].(string); ok {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func extractHydraClientLogo(metadata map[string]interface{}) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{
|
||||
"logo",
|
||||
"logo_url",
|
||||
"logoUrl",
|
||||
"logo_uri",
|
||||
"logoUri",
|
||||
"app_logo",
|
||||
"appLogo",
|
||||
}
|
||||
for _, key := range candidates {
|
||||
if value, ok := metadata[key]; ok {
|
||||
if logo, ok := value.(string); ok {
|
||||
logo = strings.TrimSpace(logo)
|
||||
if logo != "" {
|
||||
return logo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeScopes(current []string, next []string) []string {
|
||||
if len(next) == 0 {
|
||||
return current
|
||||
}
|
||||
seen := make(map[string]struct{}, len(current)+len(next))
|
||||
for _, scope := range current {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
for _, scope := range next {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
current = append(current, scope)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"errors"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
KratosAdmin *service.KratosAdminService
|
||||
OryProvider *service.OryProvider
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
return &UserHandler{DB: db}
|
||||
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider) *UserHandler {
|
||||
return &UserHandler{
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
}
|
||||
}
|
||||
|
||||
type userSummary struct {
|
||||
@@ -30,6 +33,7 @@ type userSummary struct {
|
||||
Department string `json:"department"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
InitialPassword string `json:"initialPassword,omitempty"`
|
||||
}
|
||||
|
||||
type userListResponse struct {
|
||||
@@ -40,8 +44,8 @@ type userListResponse struct {
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
@@ -55,33 +59,45 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := h.DB.Model(&domain.User{})
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("email ILIKE ? OR name ILIKE ?", like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
identities, err := h.KratosAdmin.ListIdentities(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
var users []domain.User
|
||||
if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
if search == "" {
|
||||
filtered = identities
|
||||
} else {
|
||||
searchLower := strings.ToLower(search)
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) {
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, mapUserSummary(u))
|
||||
total := int64(len(filtered))
|
||||
if offset > len(filtered) {
|
||||
offset = len(filtered)
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, end-offset)
|
||||
for _, identity := range filtered[offset:end] {
|
||||
items = append(items, mapIdentitySummary(identity))
|
||||
}
|
||||
|
||||
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -89,20 +105,20 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
|
||||
return c.JSON(mapUserSummary(user))
|
||||
return c.JSON(mapIdentitySummary(*identity))
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -122,52 +138,83 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if email == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
|
||||
}
|
||||
password := req.Password
|
||||
if password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"})
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||
}
|
||||
|
||||
// Check duplicates
|
||||
var exists domain.User
|
||||
if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
||||
password := strings.TrimSpace(req.Password)
|
||||
policy, err := h.OryProvider.GetPasswordPolicy()
|
||||
if err != nil || policy == nil {
|
||||
policy = &domain.PasswordPolicy{
|
||||
MinLength: 12,
|
||||
Lowercase: true,
|
||||
Uppercase: false,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
MinCharacterTypes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
generatedPassword := ""
|
||||
if password == "" {
|
||||
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
||||
if genErr != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"})
|
||||
}
|
||||
password = generated
|
||||
generatedPassword = generated
|
||||
} else {
|
||||
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": req.Department,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": req.CompanyCode,
|
||||
"grade": role,
|
||||
}
|
||||
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
PhoneNumber: normalizePhoneNumber(req.Phone),
|
||||
Attributes: attributes,
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
Email: email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: name,
|
||||
Phone: req.Phone,
|
||||
Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value.
|
||||
CompanyCode: req.CompanyCode,
|
||||
Department: req.Department,
|
||||
Status: "active",
|
||||
AffiliationType: "internal", // Defaulting for now
|
||||
}
|
||||
|
||||
if user.Role == "" {
|
||||
user.Role = "user"
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user))
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
}
|
||||
|
||||
response := mapIdentitySummary(*identity)
|
||||
if generatedPassword != "" {
|
||||
response.InitialPassword = generatedPassword
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -175,13 +222,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Password *string `json:"password"`
|
||||
@@ -196,46 +243,48 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = map[string]interface{}{}
|
||||
}
|
||||
if req.Name != nil {
|
||||
user.Name = strings.TrimSpace(*req.Name)
|
||||
traits["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = strings.TrimSpace(*req.Phone)
|
||||
}
|
||||
if req.Role != nil {
|
||||
user.Role = strings.TrimSpace(*req.Role)
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := strings.ToLower(strings.TrimSpace(*req.Status))
|
||||
if status == "active" || status == "inactive" || status == "blocked" {
|
||||
user.Status = status
|
||||
}
|
||||
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
user.CompanyCode = strings.TrimSpace(*req.CompanyCode)
|
||||
traits["companyCode"] = strings.TrimSpace(*req.CompanyCode)
|
||||
}
|
||||
if req.Department != nil {
|
||||
user.Department = strings.TrimSpace(*req.Department)
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
|
||||
if req.Role != nil {
|
||||
role := strings.TrimSpace(*req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
traits["grade"] = role
|
||||
}
|
||||
|
||||
if err := h.DB.Save(&user).Error; err != nil {
|
||||
state := normalizeKratosState(req.Status)
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(mapUserSummary(user))
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(mapIdentitySummary(*updated))
|
||||
}
|
||||
|
||||
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -243,25 +292,88 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil {
|
||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func mapUserSummary(u domain.User) userSummary {
|
||||
func mapIdentitySummary(identity service.KratosIdentity) userSummary {
|
||||
traits := identity.Traits
|
||||
role := extractTraitString(traits, "grade")
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
return userSummary{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
Phone: u.Phone,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CompanyCode: u.CompanyCode,
|
||||
Department: u.Department,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: extractTraitString(traits, "companyCode"),
|
||||
Department: extractTraitString(traits, "department"),
|
||||
CreatedAt: formatTime(identity.CreatedAt),
|
||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
if raw, ok := traits[key]; ok {
|
||||
if value, ok := raw.(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func normalizeStatus(state string) string {
|
||||
state = strings.ToLower(strings.TrimSpace(state))
|
||||
if state == "inactive" || state == "blocked" || state == "active" {
|
||||
return state
|
||||
}
|
||||
if state == "" {
|
||||
return "active"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func normalizeKratosState(status *string) string {
|
||||
if status == nil {
|
||||
return ""
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(*status))
|
||||
if value == "blocked" {
|
||||
return "inactive"
|
||||
}
|
||||
if value == "active" || value == "inactive" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizePhoneNumber(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
194
backend/internal/service/kratos_admin_service.go
Normal file
194
backend/internal/service/kratos_admin_service.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type KratosAdminService struct {
|
||||
AdminURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewKratosAdminService() *KratosAdminService {
|
||||
return &KratosAdminService{
|
||||
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin get identity failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identity KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
if strings.TrimSpace(state) != "" {
|
||||
payload["state"] = strings.TrimSpace(state)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin update identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var updated KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
payload := map[string]interface{}{
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"password": newPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin update password failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin delete identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) httpClient() *http.Client {
|
||||
if s.HTTPClient != nil {
|
||||
return s.HTTPClient
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getenvKratos(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
204
backend/internal/utils/password_policy.go
Normal file
204
backend/internal/utils/password_policy.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
lowercaseChars = "abcdefghijklmnopqrstuvwxyz"
|
||||
uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
numberChars = "0123456789"
|
||||
symbolChars = "!@#$%^&*()-_=+[]{}<>?/,.~"
|
||||
)
|
||||
|
||||
// ValidatePasswordWithPolicy validates a password against the given policy.
|
||||
func ValidatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if len(password) < policy.MinLength {
|
||||
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
||||
}
|
||||
|
||||
hasLower := false
|
||||
hasUpper := false
|
||||
hasNumber := false
|
||||
hasSymbol := false
|
||||
types := 0
|
||||
|
||||
for _, ch := range password {
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
hasLower = true
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
hasUpper = true
|
||||
case ch >= '0' && ch <= '9':
|
||||
hasNumber = true
|
||||
default:
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasLower {
|
||||
types++
|
||||
}
|
||||
if hasUpper {
|
||||
types++
|
||||
}
|
||||
if hasNumber {
|
||||
types++
|
||||
}
|
||||
if hasSymbol {
|
||||
types++
|
||||
}
|
||||
|
||||
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
||||
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePasswordWithPolicy creates a random password that satisfies the policy.
|
||||
func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
|
||||
if policy == nil {
|
||||
policy = &domain.PasswordPolicy{}
|
||||
}
|
||||
|
||||
categories := []struct {
|
||||
name string
|
||||
required bool
|
||||
chars string
|
||||
}{
|
||||
{name: "lower", required: policy.Lowercase, chars: lowercaseChars},
|
||||
{name: "upper", required: policy.Uppercase, chars: uppercaseChars},
|
||||
{name: "number", required: policy.Number, chars: numberChars},
|
||||
{name: "symbol", required: policy.NonAlphanumeric, chars: symbolChars},
|
||||
}
|
||||
|
||||
selected := make([]string, 0, len(categories))
|
||||
required := make([]string, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
if cat.chars == "" {
|
||||
continue
|
||||
}
|
||||
if cat.required {
|
||||
required = append(required, cat.chars)
|
||||
}
|
||||
selected = append(selected, cat.chars)
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
selected = []string{lowercaseChars, uppercaseChars, numberChars, symbolChars}
|
||||
}
|
||||
|
||||
additionalTypes := policy.MinCharacterTypes - len(required)
|
||||
if additionalTypes > 0 {
|
||||
pool := make([]string, 0, len(selected))
|
||||
for _, cat := range selected {
|
||||
isRequired := false
|
||||
for _, req := range required {
|
||||
if req == cat {
|
||||
isRequired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRequired {
|
||||
pool = append(pool, cat)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < additionalTypes && len(pool) > 0; i++ {
|
||||
idx, err := randomIndex(len(pool))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
required = append(required, pool[idx])
|
||||
pool = append(pool[:idx], pool[idx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
minLength := policy.MinLength
|
||||
if minLength <= 0 {
|
||||
minLength = 12
|
||||
}
|
||||
if minLength < len(required) {
|
||||
minLength = len(required)
|
||||
}
|
||||
|
||||
passwordRunes := make([]rune, 0, minLength)
|
||||
for _, charset := range required {
|
||||
ch, err := randomChar(charset)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
combined := ""
|
||||
for _, charset := range selected {
|
||||
combined += charset
|
||||
}
|
||||
for len(passwordRunes) < minLength {
|
||||
ch, err := randomChar(combined)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
if err := shuffleRunes(passwordRunes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(passwordRunes), nil
|
||||
}
|
||||
|
||||
func randomIndex(max int) (int, error) {
|
||||
if max <= 0 {
|
||||
return 0, fmt.Errorf("invalid max")
|
||||
}
|
||||
b := make([]byte, 1)
|
||||
for {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int(b[0]) < max*(256/max) {
|
||||
return int(b[0]) % max, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomChar(chars string) (rune, error) {
|
||||
idx, err := randomIndex(len(chars))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rune(chars[idx]), nil
|
||||
}
|
||||
|
||||
func shuffleRunes(values []rune) error {
|
||||
for i := len(values) - 1; i > 0; i-- {
|
||||
j, err := randomIndex(i + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[i], values[j] = values[j], values[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -28,6 +28,8 @@ services:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: baron_clickhouse
|
||||
restart: always
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- REDIS_ADDR=${REDIS_ADDR}
|
||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory,descope}
|
||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||
@@ -89,7 +89,6 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID}
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- APP_ENV=${APP_ENV}
|
||||
@@ -104,7 +103,6 @@ services:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
/bin/sh -c "mkdir -p /usr/share/nginx/html/assets &&
|
||||
echo \"DESCOPE_PROJECT_ID=$${DESCOPE_PROJECT_ID}\" > /usr/share/nginx/html/assets/.env &&
|
||||
echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env &&
|
||||
echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env &&
|
||||
echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env &&
|
||||
|
||||
39
docs/hydra-rp-consent-try.md
Normal file
39
docs/hydra-rp-consent-try.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Hydra RP Consent 시도 기록
|
||||
|
||||
## 목표
|
||||
- 샘플 RP(`52a597f0-5b06-4fcb-b804-93e88a56a75a`)와 사용자(`b24051@hanmaceng.co.kr`, Kratos ID: `22607c1b-bfbf-4a90-9505-36b348472e7a`) 사이에 Hydra consent 세션을 생성.
|
||||
|
||||
## 시도한 방법과 실패 원인
|
||||
|
||||
### 1) hydra 컨테이너 내부에서 `sh` 실행 후 스크립트 수행
|
||||
- 시도: `docker exec -i ory_hydra sh -lc '...'`
|
||||
- 실패: `sh`가 존재하지 않음 (Hydra 컨테이너가 distroless 이미지).
|
||||
- 원인: 쉘 바이너리 미포함.
|
||||
|
||||
### 2) `curlimages/curl` 컨테이너를 hydra 네트워크에 붙여서 consent 생성 흐름 수행
|
||||
- 시도 흐름:
|
||||
1. `/oauth2/auth` 호출로 `login_challenge` 획득
|
||||
2. Admin API로 `login_challenge` 수락
|
||||
3. `login_accept.redirect_to`(보통 `http://127.0.0.1:3000/...`)로 이동해 `consent_challenge` 추출
|
||||
- 실패: `login_accept.redirect_to`가 **consent app(127.0.0.1:3000)**로 향하는데, 해당 서비스가 떠 있지 않아 접근 불가.
|
||||
- 원인: consent app가 실행 중이 아니라 127.0.0.1:3000 접속 실패.
|
||||
|
||||
### 3) `login_accept.redirect_to`를 그대로 호출해 Location 헤더에서 consent_challenge 추출 시도
|
||||
- 실패: 위와 동일하게 consent app 경로를 직접 호출하게 되어 연결 실패.
|
||||
- 원인: consent app 미기동.
|
||||
|
||||
### 4) DCR(동적 클라이언트 등록) 시 metadata 포함 시도
|
||||
- 실패: `invalid_client_metadata` (DCR에서는 `metadata`를 설정할 수 없음)
|
||||
- 원인: Hydra DCR 정책 제한.
|
||||
|
||||
## 요약
|
||||
- **핵심 실패 원인**은 consent app(로그인/동의 UI)이 실행 중이 아니라서 redirect_to를 따라가면 접속이 불가능한 점.
|
||||
- distroless 이미지로 인해 `docker exec`로 쉘 스크립트를 바로 실행할 수 없음.
|
||||
|
||||
## 다음 시도(새 방식)
|
||||
- consent app로 직접 이동하지 않고, **Hydra public endpoint**에서 `login_verifier`를 이용해 `consent_challenge`를 추출한 뒤 Admin API로 수락.
|
||||
- 흐름:
|
||||
1. `/oauth2/auth` → `login_challenge`
|
||||
2. `/oauth2/auth/requests/login/accept` → `login_verifier`
|
||||
3. `/oauth2/auth?client_id=...&login_verifier=...` 호출 → Location 헤더에서 `consent_challenge` 추출
|
||||
4. `/oauth2/auth/requests/consent/accept` 호출
|
||||
BIN
userfront/assets/fonts/NotoSansKR-Bold.ttf
Normal file
BIN
userfront/assets/fonts/NotoSansKR-Bold.ttf
Normal file
Binary file not shown.
BIN
userfront/assets/fonts/NotoSansKR-Regular.ttf
Normal file
BIN
userfront/assets/fonts/NotoSansKR-Regular.ttf
Normal file
Binary file not shown.
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
@@ -91,7 +90,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
children: [
|
||||
Text(
|
||||
"비밀번호를 잊으셨나요?",
|
||||
style: GoogleFonts.outfit(
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||
@@ -47,6 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
int _linkExpireSeconds = 0;
|
||||
Timer? _linkExpireTimer;
|
||||
bool _verificationOnly = false;
|
||||
bool _verificationApproved = false;
|
||||
String _verificationMessage = '';
|
||||
bool _drySendEnabled = false;
|
||||
|
||||
@override
|
||||
@@ -352,15 +353,72 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_onLoginSuccess(token, provider: provider);
|
||||
}
|
||||
|
||||
bool _hasLocalSession() {
|
||||
if (AuthTokenStore.getToken() != null) {
|
||||
return true;
|
||||
}
|
||||
return AuthTokenStore.usesCookie();
|
||||
}
|
||||
|
||||
void _markVerificationApproved(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_verificationApproved = true;
|
||||
_verificationMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildVerificationResultView() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'승인 완료',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
debugPrint("[Auth] Starting verification for token: $token");
|
||||
try {
|
||||
// Use Backend to verify the token (Backend-Driven Flow)
|
||||
await AuthProxyService.verifyMagicLink(token);
|
||||
final res = await AuthProxyService.verifyMagicLink(token);
|
||||
debugPrint("[Auth] Verification successful for token: $token");
|
||||
final jwt = res['token'] ?? res['sessionJwt'];
|
||||
final provider = res['provider'] as String?;
|
||||
final hasLocalSession = _hasLocalSession();
|
||||
|
||||
if (jwt is String && jwt.isNotEmpty) {
|
||||
if (hasLocalSession) {
|
||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
||||
return;
|
||||
}
|
||||
_completeLoginFromToken(jwt, provider: provider);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||
@@ -382,23 +440,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
final status = res['status']?.toString();
|
||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
||||
final hasLocalSession = _hasLocalSession();
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_verificationOnly) {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jwt is String && jwt.isNotEmpty) {
|
||||
if (hasLocalSession) {
|
||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
||||
return;
|
||||
}
|
||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_verificationOnly && mounted) {
|
||||
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
||||
@@ -417,23 +478,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
final status = res['status']?.toString();
|
||||
debugPrint("[Auth] Short code verification successful");
|
||||
final hasLocalSession = _hasLocalSession();
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_verificationOnly) {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jwt is String && jwt.isNotEmpty) {
|
||||
if (hasLocalSession) {
|
||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
||||
return;
|
||||
}
|
||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_verificationOnly && mounted) {
|
||||
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||
@@ -784,6 +848,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_verificationOnly && _verificationApproved) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('로그인 승인'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
),
|
||||
body: _buildVerificationResultView(),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -800,7 +877,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
children: [
|
||||
Text(
|
||||
"Baron 통합로그인",
|
||||
style: GoogleFonts.outfit(
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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});
|
||||
@@ -18,7 +17,7 @@ class LoginSuccessScreen extends StatelessWidget {
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"로그인 완료",
|
||||
style: GoogleFonts.outfit(
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -206,7 +206,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
errorBuilder: (context, error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -150,7 +149,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
children: [
|
||||
Text(
|
||||
"새로운 비밀번호 설정",
|
||||
style: GoogleFonts.outfit(
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -332,7 +331,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('서비스 이용을 위해\n약관에 동의해주세요',
|
||||
style: GoogleFonts.outfit(
|
||||
style: TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)),
|
||||
const SizedBox(height: 24),
|
||||
// 모두 동의 버튼
|
||||
@@ -597,7 +596,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('본인 확인을 위해\n인증을 진행해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text('본인 확인을 위해\n인증을 진행해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
// 가족사 이메일 안내 문구
|
||||
Container(
|
||||
@@ -713,7 +712,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('회원님의\n소속 정보를 알려주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text('회원님의\n소속 정보를 알려주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -826,7 +825,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('마지막으로\n비밀번호를 설정해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text('마지막으로\n비밀번호를 설정해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
// 비밀번호 정책 안내 박스
|
||||
Container(
|
||||
@@ -918,7 +917,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: Text('회원가입', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
||||
title: Text('회원가입', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
@@ -84,6 +83,45 @@ class AuditLogEntry {
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
LinkedRp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
||||
DateTime? parsedLastAuth;
|
||||
if (rawLastAuth.isNotEmpty) {
|
||||
try {
|
||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||
} catch (_) {
|
||||
parsedLastAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedRp(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@@ -98,12 +136,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
|
||||
Future<List<AuditLogEntry>>? _auditFuture;
|
||||
Future<List<LinkedRp>>? _linkedRpsFuture;
|
||||
bool _showAllActivities = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_auditFuture = _fetchAuditLogs();
|
||||
_linkedRpsFuture = _fetchLinkedRps();
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
@@ -172,10 +212,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
await ref.read(profileProvider.notifier).loadProfile();
|
||||
setState(() {
|
||||
_auditFuture = _fetchAuditLogs();
|
||||
_linkedRpsFuture = _fetchLinkedRps();
|
||||
});
|
||||
if (_auditFuture != null) {
|
||||
await _auditFuture;
|
||||
}
|
||||
if (_linkedRpsFuture != null) {
|
||||
await _linkedRpsFuture;
|
||||
}
|
||||
}
|
||||
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -216,6 +260,37 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
return logs;
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load linked rps');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final linkedRps = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
|
||||
return linkedRps;
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
@@ -391,7 +466,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'Baron 통합로그인',
|
||||
style: GoogleFonts.outfit(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: _surface,
|
||||
@@ -442,7 +517,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
],
|
||||
_buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildActivityGrid(sessionIssuedAt, isMobile),
|
||||
_buildActivitySection(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
@@ -545,16 +620,52 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityGrid(DateTime? signupAt, bool isMobile) {
|
||||
final signupLabel = signupAt != null ? _formatDateTime(signupAt) : '확인 필요';
|
||||
final activities = [
|
||||
_ActivityItem(
|
||||
appName: 'Baron 통합로그인',
|
||||
lastAuthAt: signupLabel,
|
||||
status: '활성',
|
||||
canLogout: true,
|
||||
onLogout: _logout,
|
||||
),
|
||||
Widget _buildActivitySection(bool isMobile) {
|
||||
return FutureBuilder<List<LinkedRp>>(
|
||||
future: _linkedRpsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final activities = _buildActivityItems(snapshot.data ?? []);
|
||||
final grid = _buildActivityGrid(activities, isMobile);
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
grid,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'연동 정보를 불러오지 못했습니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return grid;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||
final items = <_ActivityItem>[];
|
||||
for (final rp in linkedRps) {
|
||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
||||
: '연동됨';
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
final statusLabel = normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성';
|
||||
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
||||
items.add(
|
||||
_ActivityItem(
|
||||
appName: name,
|
||||
lastAuthAt: lastAuthLabel,
|
||||
status: statusLabel,
|
||||
canLogout: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.addAll([
|
||||
_ActivityItem(
|
||||
appName: 'BEPs',
|
||||
lastAuthAt: '연동 필요',
|
||||
@@ -579,7 +690,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
status: '미연동',
|
||||
canLogout: false,
|
||||
),
|
||||
];
|
||||
]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||
|
||||
if (!isMobile) {
|
||||
return Wrap(
|
||||
@@ -660,7 +776,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'가입일시',
|
||||
'최근 인증',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:descope/descope.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
@@ -28,6 +27,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
TextEditingController? _phoneController;
|
||||
TextEditingController? _departmentController;
|
||||
TextEditingController? _codeController;
|
||||
final FocusNode _nameFocus = FocusNode();
|
||||
final FocusNode _departmentFocus = FocusNode();
|
||||
final FocusNode _phoneFocus = FocusNode();
|
||||
final FocusNode _phoneCodeFocus = FocusNode();
|
||||
|
||||
String _initialPhone = '';
|
||||
bool _isPhoneChanged = false;
|
||||
@@ -41,6 +44,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_phoneController?.dispose();
|
||||
_departmentController?.dispose();
|
||||
_codeController?.dispose();
|
||||
_nameFocus.dispose();
|
||||
_departmentFocus.dispose();
|
||||
_phoneFocus.dispose();
|
||||
_phoneCodeFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -153,7 +160,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyCode() async {
|
||||
Future<void> _verifyCode(UserProfile profile) async {
|
||||
final phone = _phoneController?.text ?? '';
|
||||
final code = _codeController?.text ?? '';
|
||||
if (code.isEmpty) return;
|
||||
@@ -170,6 +177,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
const SnackBar(content: Text('인증되었습니다.')),
|
||||
);
|
||||
}
|
||||
if (_editingField == 'phone') {
|
||||
await _saveField(profile);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
@@ -180,6 +190,19 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||
if (_editingField != field) return;
|
||||
if (_isVerifying) return;
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
void _handlePhoneFocusChange(UserProfile profile) {
|
||||
if (_editingField != 'phone') return;
|
||||
if (_isVerifying) return;
|
||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
Future<void> _saveField(UserProfile profile) async {
|
||||
if (_editingField == null) return;
|
||||
|
||||
@@ -431,28 +454,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
Focus(
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (!hasFocus) {
|
||||
_autoSaveIfEditing(profile, field);
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: isUpdating ? null : () => _saveField(profile),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -482,17 +501,28 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '01012345678',
|
||||
suffixIcon: _isPhoneVerified
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
child: Focus(
|
||||
focusNode: _phoneFocus,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (!hasFocus) {
|
||||
_handlePhoneFocusChange(profile);
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocus,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '01012345678',
|
||||
suffixIcon: _isPhoneVerified
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
),
|
||||
enabled: !_isPhoneVerified,
|
||||
),
|
||||
enabled: !_isPhoneVerified,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -509,18 +539,29 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '인증번호 6자리',
|
||||
child: Focus(
|
||||
focusNode: _phoneCodeFocus,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (!hasFocus) {
|
||||
_handlePhoneFocusChange(profile);
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
focusNode: _phoneCodeFocus,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _verifyCode(profile),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '인증번호 6자리',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isVerifying ? null : _verifyCode,
|
||||
onPressed: _isVerifying ? null : () => _verifyCode(profile),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
@@ -534,21 +575,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
style: TextStyle(color: Colors.orange, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: isUpdating ? null : () => _saveField(profile),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -556,59 +582,69 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
Widget _buildContent(UserProfile profile, bool isUpdating) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
_buildHeaderCard(profile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
Column(
|
||||
children: [
|
||||
_buildEditableTile(
|
||||
field: 'name',
|
||||
label: '이름',
|
||||
value: profile.name,
|
||||
profile: profile,
|
||||
isUpdating: isUpdating,
|
||||
controller: _nameController!,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('이메일', profile.email),
|
||||
const Divider(height: 24),
|
||||
_buildPhoneEditor(profile, isUpdating),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
Column(
|
||||
children: [
|
||||
_buildEditableTile(
|
||||
field: 'department',
|
||||
label: '소속',
|
||||
value: profile.department,
|
||||
profile: profile,
|
||||
isUpdating: isUpdating,
|
||||
controller: _departmentController!,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('구분', profile.affiliationType),
|
||||
if (profile.companyCode.isNotEmpty) ...[
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('회사코드', profile.companyCode),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
_buildHeaderCard(profile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
Column(
|
||||
children: [
|
||||
_buildEditableTile(
|
||||
field: 'name',
|
||||
label: '이름',
|
||||
value: profile.name,
|
||||
profile: profile,
|
||||
isUpdating: isUpdating,
|
||||
controller: _nameController!,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('이메일', profile.email),
|
||||
const Divider(height: 24),
|
||||
_buildPhoneEditor(profile, isUpdating),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
Column(
|
||||
children: [
|
||||
_buildEditableTile(
|
||||
field: 'department',
|
||||
label: '소속',
|
||||
value: profile.department,
|
||||
profile: profile,
|
||||
isUpdating: isUpdating,
|
||||
controller: _departmentController!,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('구분', profile.affiliationType),
|
||||
if (profile.companyCode.isNotEmpty) ...[
|
||||
const Divider(height: 24),
|
||||
_buildReadOnlyTile('회사코드', profile.companyCode),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUpdating || _isVerifying) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUpdating || _isVerifying) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -652,7 +688,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'Baron 통합로그인',
|
||||
style: GoogleFonts.outfit(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: _surface,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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/services.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'features/auth/presentation/login_screen.dart';
|
||||
import 'features/auth/presentation/signup_screen.dart';
|
||||
@@ -23,6 +23,18 @@ import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('Main');
|
||||
|
||||
Future<void> _loadBundledFonts() async {
|
||||
const family = 'NotoSansKR';
|
||||
final loader = FontLoader(family);
|
||||
try {
|
||||
loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Regular.ttf'));
|
||||
loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Bold.ttf'));
|
||||
await loader.load();
|
||||
} catch (e) {
|
||||
_log.warning("Failed to preload bundled fonts: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
usePathUrlStrategy();
|
||||
@@ -51,6 +63,9 @@ void main() async {
|
||||
// 0. Initialize Logger
|
||||
LoggerService.init();
|
||||
|
||||
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
||||
await _loadBundledFonts();
|
||||
|
||||
// Initialize Descope (프로젝트 ID가 없으면 경고만 남기고 진행)
|
||||
final projectId = dotenv.maybeGet('DESCOPE_PROJECT_ID') ?? '';
|
||||
if (projectId.isEmpty || projectId == 'your-project-id') {
|
||||
@@ -228,7 +243,7 @@ class BaronSSOApp extends StatelessWidget {
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
textTheme: GoogleFonts.interTextTheme(),
|
||||
fontFamily: 'NotoSansKR',
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: NoTransitionsBuilder(),
|
||||
|
||||
@@ -117,10 +117,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: descope
|
||||
sha256: da73578c619aefb82411ddca3e61423006a36900ff8cdc4b4ef42c4863f8ad36
|
||||
sha256: cae7d22e47d7d1c35d5a1cdfad2ae5f3b5e755476f215ddfa4dbeab6937f3ac2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.11"
|
||||
version: "0.9.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -133,10 +133,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -170,10 +170,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde"
|
||||
sha256: a3cd0547353c1990bf5ad64f73143e5ce7a780409639559ad83a743ff3b945e4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.2.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -184,10 +184,10 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
userfront_server_client:
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: userfront_server_client
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
@@ -208,14 +208,6 @@ packages:
|
||||
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:
|
||||
@@ -260,10 +252,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
version: "4.10.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -296,8 +288,16 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logger
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
@@ -336,6 +336,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.4"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -360,62 +368,6 @@ packages:
|
||||
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:
|
||||
@@ -440,14 +392,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59
|
||||
sha256: "9026676260f31bb5279cc5e59ca292145d8bab4aabede9aa2555c2a626ec66f1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.2.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -633,10 +601,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -665,10 +633,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -701,14 +669,6 @@ packages:
|
||||
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:
|
||||
@@ -719,4 +679,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
||||
@@ -38,13 +38,12 @@ dependencies:
|
||||
go_router: ^17.0.1
|
||||
descope: ^0.9.11
|
||||
http: ^1.6.0
|
||||
google_fonts: ^6.3.3
|
||||
flutter_dotenv: ^5.1.0
|
||||
flutter_dotenv: ^6.0.0
|
||||
url_launcher: ^6.3.2
|
||||
logging: ^1.2.0
|
||||
logger: ^2.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^6.0.0
|
||||
mobile_scanner: ^7.1.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -96,3 +95,9 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
fonts:
|
||||
- family: NotoSansKR
|
||||
fonts:
|
||||
- asset: assets/fonts/NotoSansKR-Regular.ttf
|
||||
- asset: assets/fonts/NotoSansKR-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
Reference in New Issue
Block a user