관리자 회원 관리 , 등록 , 수정, 권한변경 작업완료

This commit is contained in:
sungro815 2026-02-05 15:45:08 +09:00
parent 0010cc69be
commit 722b1b8575
28 changed files with 3264 additions and 234 deletions

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Admin;
namespace App\Http\Controllers\Admin;
use App\Services\Admin\AdminAdminsService;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
final class AdminAdminsController
{
public function __construct(
private readonly AdminAdminsService $service,
) {}
public function index(Request $request)
{
$data = $this->service->list($request->all());
return view('admin.admins.index', $data);
}
public function edit(int $id)
{
$data = $this->service->editData($id);
if (!$data['admin']) {
return redirect()->route('admin.admins.index')->with('error', '관리자를 찾을 수 없습니다.');
}
return view('admin.admins.edit', $data);
}
public function update(Request $request, int $id)
{
$res = $this->service->update($id, $request->all(), (int)auth('admin')->id());
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
->with('toast', [
'type' => 'danger',
'title' => '저장 실패',
'message' => $res['message'] ?? '수정에 실패했습니다.',
]);
}
// ✅ 수정 후에는 edit(GET)으로 보내기
return redirect()
->route('admin.admins.edit', ['id' => $id])
->with('toast', [
'type' => 'success',
'title' => '저장 완료',
'message' => '변경되었습니다.',
]);
}
public function resetPassword(int $id)
{
$res = $this->service->resetPasswordToEmail($id, (int)auth('admin')->id());
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
->with('toast', [
'type' => 'danger',
'title' => '저장 실패',
'message' => $res['message'] ?? '수정에 실패했습니다.',
]);
}
// ✅ 수정 후에는 edit(GET)으로 보내기
return redirect()
->route('admin.admins.edit', ['id' => $id])
->with('toast', [
'type' => 'success',
'title' => '저장 완료',
'message' => '변경되었습니다.',
]);
}
public function unlock(int $id)
{
$res = $this->service->unlock($id, (int)auth('admin')->id());
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
->with('toast', [
'type' => 'danger',
'title' => '저장 실패',
'message' => $res['message'] ?? '수정에 실패했습니다.',
]);
}
// 수정 후에는 edit(GET)으로 보내기
return redirect()
->route('admin.admins.edit', ['id' => $id])
->with('toast', [
'type' => 'success',
'title' => '저장 완료',
'message' => '변경되었습니다.',
]);
}
public function create(Request $request)
{
// 단일 선택 role 목록 (value는 code)
$roles = $this->service->getAssignableRoles(); // 아래 서비스에 추가
return view('admin.admins.create', [
'roles' => $roles,
'filters' => $request->only(['q','status','page']),
]);
}
public function store(Request $request)
{
$data = $request->validate([
'email' => ['required', 'string', 'email', 'max:190', 'unique:admin_users,email'],
'name' => ['required', 'string', 'max:60'],
'nickname' => ['required', 'string', 'max:60'],
'phone' => ['required', 'string', 'max:30'],
'role' => ['required', 'string', 'in:super_admin,finance,product,support'],
]);
// phoneDigits: 숫자만 10~11
$phoneDigits = preg_replace('/\D+/', '', (string)($data['phone'] ?? ''));
if (!preg_match('/^\d{10,11}$/', $phoneDigits)) {
return back()->withErrors(['phone' => '휴대폰 번호는 숫자만 10~11자리로 입력해 주세요.'])->withInput();
}
$actorId = (int) auth('admin')->id();
$res = $this->service->createAdmin([
'email' => strtolower(trim($data['email'])),
'name' => $data['name'],
'nickname' => $data['nickname'],
'phone_digits' => $phoneDigits,
'role' => $data['role'],
], $actorId);
if (!($res['ok'] ?? false)) {
return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput();
}
// ✅ 임시 비밀번호는 “1회 안내”를 위해 메시지에 포함(원하면 문구만 바꿔도 됨)
$temp = (string)($res['temp_password'] ?? '');
return redirect()
->route('admin.admins.edit', ['id' => (int)($res['admin_id'] ?? 0)])
->with('status', $temp !== ''
? "관리자 계정이 등록되었습니다. 임시 비밀번호: {$temp} (다음 로그인 시 변경 강제)"
: "관리자 계정이 등록되었습니다. (다음 로그인 시 비밀번호 변경 강제)"
);
}
}

View File

@ -23,12 +23,11 @@ final class AdminAuthController extends Controller
public function storeLogin(Request $request) public function storeLogin(Request $request)
{ {
$rules = [ $rules = [
'login_id' => ['required', 'string', 'max:190'], // admin_users.email(190) 'login_id' => ['required', 'string', 'max:190'],
'password' => ['required', 'string', 'max:255'], 'password' => ['required', 'string', 'max:255'],
'remember' => ['nullable'], 'remember' => ['nullable'],
]; ];
// 운영에서만 reCAPTCHA 필수
if (app()->environment('production')) { if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('admin_login')]; $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('admin_login')];
} }
@ -46,46 +45,116 @@ final class AdminAuthController extends Controller
ip: $ip ip: $ip
); );
if (($res['state'] ?? '') === 'invalid') { $state = (string) ($res['state'] ?? '');
return back()->withErrors(['login_id' => '이메일 또는 비밀번호를 확인하세요.'])->withInput();
// ✅ 1) 계정 잠김
if ($state === 'locked') {
$msg = '계정이 잠금 상태입니다. 최고관리자에게 잠금 해제를 요청해 주세요.';
return back()
->withInput()
->withErrors(['login_id' => $msg])
->with('toast', [
'type' => 'danger',
'title' => '계정 잠김',
'message' => $msg,
]);
} }
if (($res['state'] ?? '') === 'sms_error') { // ✅ 2) 비번 불일치/계정없음 (남은 시도 횟수 포함)
return back()->withErrors(['login_id' => '인증 sms 발송에 실패하였습니다.'])->withInput(); if ($state === 'invalid') {
$left = $res['attempts_left'] ?? null;
$msg = '이메일 또는 비밀번호를 확인하세요.';
if ($left !== null && is_numeric($left)) {
$msg .= ' (남은 시도 ' . (int)$left . '회)';
} }
if (($res['state'] ?? '') === 'blocked') { return back()
return back()->withErrors(['login_id' => '로그인 할 수 없는 계정입니다.'])->withInput(); ->withInput()
->withErrors(['login_id' => $msg])
->with('toast', [
'type' => 'danger',
'title' => '로그인 실패',
'message' => $msg,
]);
} }
if (($res['state'] ?? '') === 'must_reset') { // ✅ 3) 차단/비활성 계정
if ($state === 'blocked') {
$msg = '로그인 할 수 없는 계정입니다.';
return back()
->withInput()
->withErrors(['login_id' => $msg])
->with('toast', [
'type' => 'danger',
'title' => '접근 불가',
'message' => $msg,
]);
}
// ✅ 4) SMS 발송 실패
if ($state === 'sms_error') {
$msg = '인증 SMS 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.';
return back()
->withInput()
->withErrors(['login_id' => $msg])
->with('toast', [
'type' => 'danger',
'title' => 'SMS 오류',
'message' => $msg,
]);
}
// ✅ 5) 비번 재설정 강제
if ($state === 'must_reset') {
$request->session()->put('admin_pwreset', [ $request->session()->put('admin_pwreset', [
'admin_id' => (int) ($res['admin_id'] ?? 0), 'admin_id' => (int) ($res['admin_id'] ?? 0),
'email' => $email, 'email' => $email,
'expires_at' => time() + 600, // 10분 'expires_at' => time() + 600,
'ip' => $ip, 'ip' => $ip,
]); ]);
return redirect()->route('admin.password.reset.form') return redirect()
->with('status', '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.'); ->route('admin.password.reset.form')
->with('toast', [
'type' => 'info',
'title' => '비밀번호 변경 필요',
'message' => '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.',
]);
} }
if (($res['state'] ?? '') !== 'otp_sent') { // ✅ 6) OTP 발송 성공
// 방어: 예상치 못한 상태 if ($state === 'otp_sent') {
return back()->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])->withInput();
}
// ✅ OTP 챌린지 ID만 세션에 보관 (OTP 평문/해시 세션 저장 X)
$request->session()->put('admin_2fa', [ $request->session()->put('admin_2fa', [
'challenge_id' => (string) ($res['challenge_id'] ?? ''), 'challenge_id' => (string) ($res['challenge_id'] ?? ''),
'masked_phone' => (string) ($res['masked_phone'] ?? ''), 'masked_phone' => (string) ($res['masked_phone'] ?? ''),
'expires_at' => time() + (int) config('admin.sms_ttl', 180), 'expires_at' => time() + (int) config('admin.sms_ttl', 180),
]); ]);
return redirect()->route('admin.otp.form') return redirect()
->with('status', '인증번호를 문자로 발송했습니다.'); ->route('admin.otp.form')
->with('toast', [
'type' => 'success',
'title' => '인증번호 발송',
'message' => '인증번호를 문자로 발송했습니다.',
]);
} }
// ✅ 방어: 예상치 못한 상태
return back()
->withInput()
->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])
->with('toast', [
'type' => 'danger',
'title' => '처리 오류',
'message' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
]);
}
// 비밀번호 초기화 폼 // 비밀번호 초기화 폼
public function showForceReset(Request $request) public function showForceReset(Request $request)
{ {

View File

@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Services\Admin\AdminMeService;
use Illuminate\Http\Request;
use App\Repositories\Admin\AdminUserRepository;
final class MeController
{
public function __construct(
private readonly AdminMeService $service
) {}
public function show(Request $request)
{
$me = $request->user('admin');
$phonePlain = $this->service->decryptPhoneForMe($me);
$repo = app(AdminUserRepository::class);
$roles = $repo->getRolesForUser((int)$me->id);
$perms = $repo->getPermissionsForUser((int)$me->id);
return view('admin.me.show', [
'me' => $me,
'phone_plain' => $phonePlain,
'roles' => $roles,
'perms' => $perms,
]);
}
public function update(Request $request)
{
$me = $request->user('admin');
$res = $this->service->updateProfile($me->id, $request);
if (!($res['ok'] ?? false)) {
return redirect()
->back()
->withInput()
->with('toast', [
'type' => 'danger',
'title' => '저장 실패',
'message' => $res['message'] ?? '수정에 실패했습니다.',
]);
}
// ✅ 핵심: 성공도 view() 말고 redirect
return redirect()
->route('admin.me')
->with('toast', [
'type' => 'success',
'title' => '저장 완료',
'message' => '변경되었습니다.',
]);
}
public function showPassword()
{
return view('admin.me.password');
}
public function updatePassword(Request $request)
{
$me = $request->user('admin');
$res = $this->service->changePassword($me->id, $request);
if (!($res['ok'] ?? false)) {
return redirect()
->back()
->withInput()
->with('toast', [
'type' => 'danger',
'title' => '저장 실패',
'message' => $res['message'] ?? '수정에 실패했습니다.',
]);
}
// ✅ 핵심: 성공도 view() 말고 redirect
return redirect()
->route('admin.me')
->with('toast', [
'type' => 'success',
'title' => '저장 완료',
'message' => '변경되었습니다.',
]);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class NoStore
{
public function handle(Request $request, Closure $next)
{
$res = $next($request);
return $res->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->header('Pragma', 'no-cache')
->header('Expires', '0');
}
}

View File

@ -30,4 +30,16 @@ class AdminUser extends Authenticatable
'totp_verified_at' => 'datetime', 'totp_verified_at' => 'datetime',
]; ];
public function getTwoFactorModeAttribute(): string
{
$hasSecret = !empty($this->totp_secret_enc);
return ((int)($this->totp_enabled ?? 0) === 1 && $hasSecret) ? 'otp' : 'sms';
}
public function getTotpRegisteredAttribute(): bool
{
return !empty($this->totp_secret_enc);
}
} }

View File

@ -3,9 +3,21 @@
namespace App\Repositories\Admin; namespace App\Repositories\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AdminUserRepository final class AdminUserRepository
{ {
/** @var array<string, array<int, string>> */
private array $colsCache = [];
/** @var array<string, string|null> */
private array $colTypeCache = [];
// =========================
// Basic Find
// =========================
public function findByEmail(string $email): ?AdminUser public function findByEmail(string $email): ?AdminUser
{ {
return AdminUser::query() return AdminUser::query()
@ -21,18 +33,457 @@ final class AdminUserRepository
->first(); ->first();
} }
public function touchLogin(AdminUser $admin, string $ip): void public function find(int $id): ?object
{ {
$admin->last_login_at = now(); return DB::table('admin_users')->where('id', $id)->first();
$admin->last_login_ip = inet_pton($ip) ?: null;
$admin->save();
} }
// =========================
// Login Touch
// =========================
public function touchLogin(AdminUser $admin, string $ip, ?string $ua = null): void
{
$data = [
'last_login_at' => now(),
];
// last_login_ip 타입이 BINARY일 수도, VARCHAR일 수도 있어서 타입별 처리
if ($this->hasCol('admin_users', 'last_login_ip')) {
$type = $this->getColumnType('admin_users', 'last_login_ip'); // e.g. binary, varbinary, varchar...
if ($type && (str_contains($type, 'binary') || str_contains($type, 'blob'))) {
$data['last_login_ip'] = inet_pton($ip) ?: null;
} else {
$data['last_login_ip'] = $ip ?: null;
}
}
if ($ua !== null && $this->hasCol('admin_users', 'last_login_ua')) {
$data['last_login_ua'] = $ua;
}
$this->save($admin, $data);
}
// =========================
// Password
// =========================
public function setPassword(AdminUser $admin, string $plainPassword): void public function setPassword(AdminUser $admin, string $plainPassword): void
{ {
$admin->password = $plainPassword; // ✅ 캐스트가 알아서 해싱함 // ✅ AdminUser 모델에 password cast(hashed)가 있으면 plain을 넣어도 해싱됨
$admin->must_reset_password = 0; $data = [
'password' => $plainPassword,
'must_reset_password' => 0,
'password_changed_at' => now(),
];
$this->save($admin, $data);
}
public function setTemporaryPassword(AdminUser $admin, string $plainPassword, int $actorId = 0): void
{
$data = [
'password' => $plainPassword, // 모델 cast가 해싱
'must_reset_password' => 1,
'password_changed_at' => now(),
'updated_by' => $actorId ?: null,
];
$this->save($admin, $data);
}
// =========================
// Update (DB safe)
// =========================
public function updateById(int $id, array $data): bool
{
$data['updated_at'] = $data['updated_at'] ?? now();
$data = $this->filterToExistingColumns('admin_users', $data);
return DB::table('admin_users')
->where('id', $id)
->update($data) > 0;
}
/** 기존 코드 호환용 */
public function update(int $id, array $data): bool
{
return $this->updateById($id, $data);
}
public function save(AdminUser $admin, array $data): void
{
$data = $this->filterToExistingColumns('admin_users', $data);
foreach ($data as $k => $v) {
$admin->{$k} = $v;
}
$admin->save(); $admin->save();
} }
public function existsPhoneHash(string $hash, int $ignoreId): bool
{
return DB::table('admin_users')
->whereNull('deleted_at')
->where('phone_hash', $hash)
->where('id', '!=', $ignoreId)
->exists();
}
// =========================
// List / Search
// =========================
public function paginateUsers(array $filters, int $perPage = 20): LengthAwarePaginator
{
$q = AdminUser::query();
$keyword = trim((string)($filters['q'] ?? ''));
if ($keyword !== '') {
$q->where(function ($w) use ($keyword) {
if ($this->hasCol('admin_users', 'email')) $w->orWhere('email', 'like', "%{$keyword}%");
if ($this->hasCol('admin_users', 'name')) $w->orWhere('name', 'like', "%{$keyword}%");
if ($this->hasCol('admin_users', 'nickname')) $w->orWhere('nickname', 'like', "%{$keyword}%");
});
}
$status = (string)($filters['status'] ?? '');
if ($status !== '' && $this->hasCol('admin_users', 'status')) {
$q->where('status', $status);
}
$role = (string)($filters['role'] ?? '');
if ($role !== '' && Schema::hasTable('admin_role_user') && Schema::hasTable('admin_roles')) {
$roleNameCol = $this->firstExistingColumn('admin_roles', ['name','code','slug']);
if ($roleNameCol) {
$q->whereIn('id', function ($sub) use ($role, $roleNameCol) {
$sub->select('aru.admin_user_id')
->from('admin_role_user as aru')
->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id')
->where("r.{$roleNameCol}", $role);
});
}
}
if ($this->hasCol('admin_users', 'last_login_at')) {
$q->orderByDesc('last_login_at');
} else {
$q->orderByDesc('id');
}
return $q->paginate($perPage)->withQueryString();
}
// =========================
// Roles / Permissions
// (현재 DB: roles.name/label, perms.name/label 기반도 지원)
// =========================
public function getAllRoles(): array
{
if (!Schema::hasTable('admin_roles')) return [];
$codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']);
$nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']);
return DB::table('admin_roles')
->orderBy('id')
->get([
'id',
DB::raw(($codeCol ? "`{$codeCol}`" : "CAST(id AS CHAR)") . " as code"),
DB::raw(($nameCol ? "`{$nameCol}`" : "CAST(id AS CHAR)") . " as name"),
])
->map(fn($r)=>(array)$r)
->all();
}
public function findRoleIdByCode(string $code): ?int
{
if (!Schema::hasTable('admin_roles')) return null;
$codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']);
if (!$codeCol) return null;
$id = DB::table('admin_roles')->where($codeCol, $code)->value('id');
return $id ? (int)$id : null;
}
public function getRoleIdsForUser(int $adminUserId): array
{
if (!Schema::hasTable('admin_role_user')) return [];
return DB::table('admin_role_user')
->where('admin_user_id', $adminUserId)
->pluck('admin_role_id')
->map(fn($v)=>(int)$v)
->all();
}
/** ✅ (중복 선언 금지) 상세/내정보/관리페이지 공용 */
public function getRolesForUser(int $adminUserId): array
{
if (!Schema::hasTable('admin_roles') || !Schema::hasTable('admin_role_user')) return [];
$codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']);
$nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']);
return DB::table('admin_role_user as aru')
->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id')
->where('aru.admin_user_id', $adminUserId)
->orderBy('r.id')
->get([
'r.id',
DB::raw(($codeCol ? "r.`{$codeCol}`" : "CAST(r.id AS CHAR)") . " as code"),
DB::raw(($nameCol ? "r.`{$nameCol}`" : "CAST(r.id AS CHAR)") . " as name"),
])
->map(fn($row)=>(array)$row)
->all();
}
public function getRoleMapForUsers(array $userIds): array
{
if (empty($userIds)) return [];
if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_roles')) return [];
$codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']);
$nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']);
$rows = DB::table('admin_role_user as aru')
->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id')
->whereIn('aru.admin_user_id', $userIds)
->get([
'aru.admin_user_id as uid',
'r.id as rid',
DB::raw(($codeCol ? "r.`{$codeCol}`" : "CAST(r.id AS CHAR)") . " as code"),
DB::raw(($nameCol ? "r.`{$nameCol}`" : "CAST(r.id AS CHAR)") . " as name"),
]);
$map = [];
foreach ($rows as $row) {
$uid = (int)$row->uid;
$map[$uid] ??= [];
$map[$uid][] = ['id'=>(int)$row->rid,'code'=>(string)$row->code,'name'=>(string)$row->name];
}
return $map;
}
public function getPermissionsForUser(int $adminUserId): array
{
if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_permission_role') || !Schema::hasTable('admin_permissions')) {
return [];
}
$codeCol = $this->firstExistingColumn('admin_permissions', ['code','name','slug']);
$nameCol = $this->firstExistingColumn('admin_permissions', ['label','title','name']);
return DB::table('admin_role_user as aru')
->join('admin_permission_role as apr', 'apr.admin_role_id', '=', 'aru.admin_role_id')
->join('admin_permissions as p', 'p.id', '=', 'apr.admin_permission_id')
->where('aru.admin_user_id', $adminUserId)
->distinct()
->orderBy('p.id')
->get([
'p.id',
DB::raw(($codeCol ? "p.`{$codeCol}`" : "CAST(p.id AS CHAR)") . " as code"),
DB::raw(($nameCol ? "p.`{$nameCol}`" : "CAST(p.id AS CHAR)") . " as name"),
])
->map(fn($row)=>(array)$row)
->all();
}
public function getPermissionCountMapForUsers(array $userIds): array
{
if (empty($userIds)) return [];
if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_permission_role')) return [];
$rows = DB::table('admin_role_user as aru')
->join('admin_permission_role as apr', 'apr.admin_role_id', '=', 'aru.admin_role_id')
->whereIn('aru.admin_user_id', $userIds)
->select('aru.admin_user_id as uid', DB::raw('COUNT(DISTINCT apr.admin_permission_id) as cnt'))
->groupBy('aru.admin_user_id')
->get();
$map = [];
foreach ($rows as $r) $map[(int)$r->uid] = (int)$r->cnt;
return $map;
}
public function syncRoles(int $adminUserId, array $roleIds, int $actorId): void
{
if (!Schema::hasTable('admin_role_user')) return;
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
DB::table('admin_role_user')
->where('admin_user_id', $adminUserId)
->whereNotIn('admin_role_id', $roleIds)
->delete();
$existing = DB::table('admin_role_user')
->where('admin_user_id', $adminUserId)
->pluck('admin_role_id')
->map(fn($v)=>(int)$v)->all();
$need = array_values(array_diff($roleIds, $existing));
foreach ($need as $rid) {
$row = [
'admin_user_id' => $adminUserId,
'admin_role_id' => (int)$rid,
];
if ($this->hasCol('admin_role_user', 'assigned_by')) $row['assigned_by'] = $actorId;
if ($this->hasCol('admin_role_user', 'assigned_at')) $row['assigned_at'] = now();
DB::table('admin_role_user')->insert($row);
}
}
// =========================
// Helpers
// =========================
private function filterToExistingColumns(string $table, array $data): array
{
$cols = $this->cols($table);
$out = [];
foreach ($data as $k => $v) {
if (in_array($k, $cols, true)) {
// null 허용 컬럼만 null 넣고 싶으면 여기서 정책 추가 가능
$out[$k] = $v;
}
}
return $out;
}
private function cols(string $table): array
{
if (!isset($this->colsCache[$table])) {
$this->colsCache[$table] = Schema::hasTable($table) ? Schema::getColumnListing($table) : [];
}
return $this->colsCache[$table];
}
private function hasCol(string $table, string $col): bool
{
return in_array($col, $this->cols($table), true);
}
private function firstExistingColumn(string $table, array $candidates): ?string
{
$cols = $this->cols($table);
foreach ($candidates as $c) {
if (in_array($c, $cols, true)) return $c;
}
return null;
}
private function getColumnType(string $table, string $col): ?string
{
$key = "{$table}.{$col}";
if (array_key_exists($key, $this->colTypeCache)) {
return $this->colTypeCache[$key];
}
try {
$row = DB::selectOne("SHOW COLUMNS FROM `{$table}` LIKE ?", [$col]);
$type = $row?->Type ?? null; // e.g. varbinary(16), varchar(45)
$this->colTypeCache[$key] = $type ? strtolower((string)$type) : null;
return $this->colTypeCache[$key];
} catch (\Throwable $e) {
$this->colTypeCache[$key] = null;
return null;
}
}
public function bumpLoginFail(int $id, int $limit = 3): array
{
return DB::transaction(function () use ($id, $limit) {
$row = DB::table('admin_users')
->select('failed_login_count', 'locked_until')
->where('id', $id)
->lockForUpdate()
->first();
$cur = (int)($row->failed_login_count ?? 0);
$next = $cur + 1;
$locked = ($row->locked_until ?? null) !== null;
$update = [
'failed_login_count' => $next,
'updated_at' => now(),
];
// ✅ 3회 이상이면 "영구잠금"
if (!$locked && $next >= $limit) {
$update['locked_until'] = now();
$locked = true;
}
DB::table('admin_users')->where('id', $id)->update($update);
return ['count' => $next, 'locked' => $locked];
});
}
/**
* 로그인 성공 실패 카운터/잠금 초기화
*/
public function clearLoginFailAndUnlock(int $id): void
{
DB::table('admin_users')
->where('id', $id)
->update([
'failed_login_count' => 0,
'locked_until' => null,
'updated_at' => now(),
]);
}
public function existsByEmail(string $email): bool
{
return DB::table('admin_users')->where('email', $email)->exists();
}
public function existsByPhoneHash(string $phoneHash): bool
{
return DB::table('admin_users')->where('phone_hash', $phoneHash)->exists();
}
public function insertAdminUser(array $data): int
{
// admin_users 컬럼 그대로 사용 (DB 구조 변경 X)
$id = DB::table('admin_users')->insertGetId($data);
return (int)$id;
}
public function findRoleByCode(string $code): ?array
{
$row = DB::table('admin_roles')->where('name', $code)->first();
if (!$row) return null;
return (array)$row;
}
public function deleteRoleMappingsExcept(int $adminId, int $roleId): void
{
DB::table('admin_role_user')
->where('admin_user_id', $adminId)
->where('admin_role_id', '!=', $roleId)
->delete();
}
public function upsertRoleMapping(int $adminId, int $roleId): void
{
$table = 'admin_role_user';
$vals = [];
$now = now();
// 컬럼 있으면 채움
if (Schema::hasColumn($table, 'assigned_at')) $vals['assigned_at'] = $now;
if (Schema::hasColumn($table, 'created_at')) $vals['created_at'] = $now;
if (Schema::hasColumn($table, 'updated_at')) $vals['updated_at'] = $now;
DB::table($table)->updateOrInsert(
['admin_user_id' => $adminId, 'admin_role_id' => $roleId],
$vals
);
}
} }

View File

@ -0,0 +1,306 @@
<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class AdminAdminsService
{
public function __construct(
private readonly AdminUserRepository $repo,
) {}
public function list(array $filters): array
{
$page = $this->repo->paginateUsers($filters, 20);
$ids = $page->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all();
$roleMap = $this->repo->getRoleMapForUsers($ids);
// permission count는 테이블 없을 수도 있으니 안전하게
$permCnt = method_exists($this->repo, 'getPermissionCountMapForUsers')
? $this->repo->getPermissionCountMapForUsers($ids)
: [];
return [
'page' => $page,
'filters' => $filters,
'roleMap' => $roleMap,
'permCnt' => $permCnt,
];
}
public function editData(int $id): array
{
$admin = $this->repo->find($id);
$roles = $admin ? $this->repo->getRolesForUser($id) : [];
$allRoles = $this->repo->getAllRoles();
$roleIds = $admin ? $this->repo->getRoleIdsForUser($id) : [];
// phone 보여주기(암호화/시리얼라이즈 혼재 방어)
$phone = null;
if ($admin && property_exists($admin, 'phone_enc') && $admin->phone_enc) {
$phone = $this->readPhone((string)$admin->phone_enc);
}
return [
'admin' => $admin,
'phone' => $phone,
'roles' => $roles,
'allRoles' => $allRoles,
'roleIds' => $roleIds,
];
}
public function update(int $id, array $input, int $actorId): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
// 본인 계정 비활성화 방지(사고 방지)
if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') {
return $this->fail('본인 계정은 비활성화할 수 없습니다.');
}
$wantTotp = (int)($data['totp_enabled'] ?? 0) === 1;
if ($wantTotp && empty($admin->totp_secret_enc)) {
return $this->fail('Google OTP가 미등록 상태라 선택할 수 없습니다.');
}
$data = [
'name' => trim((string)($input['name'] ?? '')),
'nickname' => trim((string)($input['nickname'] ?? '')),
'status' => (string)($input['status'] ?? 'active'),
'totp_enabled' => $wantTotp ? 1 : 0,
'updated_by' => $actorId ?: null,
];
// phone 처리(컬럼 있는 경우만)
$rawPhone = trim((string)($input['phone'] ?? ''));
if ($rawPhone !== '') {
$phone = $this->normalizeKrPhone($rawPhone);
if ($phone === '') return $this->fail('휴대폰 번호 형식이 올바르지 않습니다.');
$hashKey = (string)config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.');
$e164 = $this->toE164Kr($phone);
$hash = hash_hmac('sha256', $e164, $hashKey);
if (method_exists($this->repo, 'existsPhoneHash') && $this->repo->existsPhoneHash($hash, $id)) {
return $this->fail('이미 사용 중인 휴대폰 번호입니다.');
}
$data['phone_hash'] = $hash;
$data['phone_enc'] = Crypt::encryptString($e164);
if (Schema::hasColumn('admin_users', 'phone_last4')) {
$data['phone_last4'] = substr(preg_replace('/\D+/', '', $phone), -4);
}
}
// null은 업데이트 제외(컬럼 유무 필터는 repo에서 함)
$data = array_filter($data, fn($v)=>$v !== null);
$ok = $this->repo->updateById($id, $data);
if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.');
// roles sync
$roleIds = $input['role_ids'] ?? [];
if (is_array($roleIds)) {
$roleIds = array_values(array_filter(array_map('intval', $roleIds), fn($v)=>$v>0));
$this->repo->syncRoles($id, $roleIds, $actorId);
}
return $this->ok('변경되었습니다.');
}
public function resetPasswordToEmail(int $id, int $actorId): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
$email = (string)($admin->email ?? '');
if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.');
$payload = [
'password' => Hash::make($email),
'must_reset_password' => 1,
'password_changed_at' => now(),
'updated_by' => $actorId ?: null,
'updated_at' => now(),
];
// 임시 비밀번호 = 이메일
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return $this->fail('비밀번호 초기화에 실패했습니다.');
return $this->ok('임시 비밀번호가 이메일로 초기화되었습니다. (로그인 후 OTP 진행)');
}
public function unlock(int $id, int $actorId): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
$ok = $this->repo->updateById($id, [
'locked_until' => null,
'failed_login_count' => 0,
'updated_by' => $actorId ?: null,
]);
if (!$ok) return $this->fail('잠금 해제에 실패했습니다.');
return $this->ok('잠금이 해제되었습니다.');
}
private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; }
private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; }
private function normalizeKrPhone(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '';
if (str_starts_with($n, '82')) $n = '0'.substr($n, 2);
if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return '';
return $n;
}
private function toE164Kr(string $raw010): string
{
$n = preg_replace('/\D+/', '', $raw010) ?? '';
if ($n === '') return '+82';
if (str_starts_with($n, '0')) $n = substr($n, 1);
return '+82'.$n;
}
private function readPhone(string $phoneEnc): ?string
{
// s:11:"010..."; 형태(구데이터) 방어: 그냥 숫자만 뽑아냄
if (preg_match('/^s:\d+:"([^"]+)";$/', $phoneEnc, $m)) {
$n = preg_replace('/\D+/', '', $m[1]) ?? '';
return $n ?: null;
}
// decrypt 시도
try {
$v = Crypt::decryptString($phoneEnc);
$n = preg_replace('/\D+/', '', $v) ?? '';
if ($n === '') return null;
if (str_starts_with($n, '82')) $n = '0'.substr($n, 2);
return $n;
} catch (\Throwable $e) {
return null;
}
}
public function getAssignableRoles(): array
{
// value(code) => label
return [
['code' => 'super_admin', 'label' => '최고관리자'],
['code' => 'finance', 'label' => '정산관리'],
['code' => 'product', 'label' => '상품관리'],
['code' => 'support', 'label' => 'CS/상담'],
];
}
public function createAdmin(array $payload, int $actorId): array
{
$email = (string)($payload['email'] ?? '');
$name = (string)($payload['name'] ?? '');
$nick = (string)($payload['nickname'] ?? '');
$roleCode = (string)($payload['role'] ?? '');
$phoneDigits = (string)($payload['phone_digits'] ?? '');
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') {
return $this->fail('설정 오류: ADMIN_PHONE_HASH_KEY 가 비어있어 등록할 수 없습니다.');
}
// role 존재 확인 (DB에 실제 role row가 있어야 함)
$role = $this->repo->findRoleByCode($roleCode);
if (!$role) {
return $this->fail('역할(Role) 설정이 올바르지 않습니다. (role not found)');
}
$roleId = (int)($role['id'] ?? 0);
if ($roleId < 1) {
return $this->fail('역할(Role) 설정이 올바르지 않습니다.');
}
// phone_* 생성
$phoneEnc = Crypt::encryptString($phoneDigits);
$phoneLast4 = substr($phoneDigits, -4);
$phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey);
// 임시 비밀번호(서버 생성)
$tempPassword = $email;
$pwHash = Hash::make($tempPassword); // 단방향 해시 (복호화 불가)
try {
return DB::transaction(function () use (
$email, $name, $nick,
$phoneEnc, $phoneHash, $phoneLast4,
$pwHash, $tempPassword,
$roleId, $actorId
) {
// 중복 방어(이메일/휴대폰)
if ($this->repo->existsByEmail($email)) {
return $this->fail('이미 등록된 이메일입니다.');
}
if ($this->repo->existsByPhoneHash($phoneHash)) {
return $this->fail('이미 등록된 휴대폰 번호입니다.');
}
// ✅ admin_users insert
$adminId = $this->repo->insertAdminUser([
'email' => $email,
'password' => $pwHash,
'name' => $name,
'nickname' => $nick,
'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash,
'phone_last4' => $phoneLast4,
'status' => 'active',
'must_reset_password' => 1,
// 신규는 기본 SMS 인증으로 시작(너 정책: 0=sms, 1=otp)
'totp_enabled' => 0,
'totp_secret_enc' => null,
'totp_verified_at' => null,
'failed_login_count' => 0,
'locked_until' => null,
'created_by' => $actorId ?: null,
'updated_by' => $actorId ?: null,
'created_at' => now(),
'updated_at' => now(),
]);
if ($adminId < 1) {
return $this->fail('관리자 생성에 실패했습니다.');
}
// 단일 role 유지: 기존 role 매핑 제거 후 updateOrInsert
$this->repo->deleteRoleMappingsExcept($adminId, $roleId);
$this->repo->upsertRoleMapping($adminId, $roleId);
return [
'ok' => true,
'admin_id' => $adminId,
'temp_password' => '', // 노출 안함(어차피 이메일)
'message' => '관리자 계정이 등록되었습니다. 임시 비밀번호는 이메일입니다. (다음 로그인 시 변경 강제)',
];
});
} catch (\Throwable $e) {
return $this->fail('등록 중 오류가 발생했습니다. (DB)');
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Admin;
use Illuminate\Support\Facades\DB;
final class AdminAuditService
{
public function log(
int $actorAdminId,
string $action,
string $targetType,
int $targetId,
?array $before,
?array $after,
string $ip = '',
string $ua = '',
): void {
DB::table('admin_audit_logs')->insert([
'actor_admin_user_id' => $actorAdminId,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'before_json' => $before ? json_encode($before, JSON_UNESCAPED_UNICODE) : null,
'after_json' => $after ? json_encode($after, JSON_UNESCAPED_UNICODE) : null,
'ip' => $ip ?: null,
'user_agent' => $ua ?: null,
'created_at' => now(),
]);
}
}

View File

@ -29,15 +29,41 @@ final class AdminAuthService
{ {
$admin = $this->users->findByEmail($email); $admin = $this->users->findByEmail($email);
// 계정 없거나 비번 불일치: 같은 메시지로 // ✅ 계정 없으면 invalid (계정 존재 여부 노출 방지)
if (!$admin || !Hash::check($password, (string)$admin->password)) { if (!$admin) {
return ['state' => 'invalid']; return ['state' => 'invalid'];
} }
// ✅ 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자)
if (($admin->status ?? 'blocked') !== 'active') { if (($admin->status ?? 'blocked') !== 'active') {
return ['state' => 'blocked']; return ['state' => 'blocked'];
} }
// ✅ 잠금 체크: locked_until != null 이면 "잠금 상태"(영구잠금)
if (($admin->locked_until ?? null) !== null) {
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
}
// ✅ 비밀번호 검증
if (!Hash::check($password, (string)$admin->password)) {
// 실패 카운트 +1, 3회 이상이면 잠금
$bumped = $this->users->bumpLoginFail((int)$admin->id, 3);
if (($bumped['locked'] ?? false) === true) {
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
}
$left = max(0, 3 - (int)($bumped['count'] ?? 0));
return ['state' => 'invalid', 'attempts_left' => $left];
}
// ✅ 3회 안에 성공하면 실패/잠금 초기화
if ((int)($admin->failed_login_count ?? 0) > 0 || ($admin->locked_until ?? null) !== null) {
$this->users->clearLoginFailAndUnlock((int)$admin->id);
}
// ✅ 비번 리셋 강제 정책
if ((int)($admin->must_reset_password ?? 0) === 1) { if ((int)($admin->must_reset_password ?? 0) === 1) {
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id]; return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
} }
@ -45,6 +71,7 @@ final class AdminAuthService
// phone_enc 복호화(E164 or digits) // phone_enc 복호화(E164 or digits)
$phoneDigits = $this->decryptPhoneToDigits($admin); $phoneDigits = $this->decryptPhoneToDigits($admin);
if ($phoneDigits === '') { if ($phoneDigits === '') {
// 내부 데이터 문제라서 카운트 올리진 않음(원하면 올려도 됨)
return ['state' => 'invalid']; return ['state' => 'invalid'];
} }
@ -84,12 +111,11 @@ final class AdminAuthService
if (!$ok) { if (!$ok) {
Cache::store('redis')->forget($this->otpKey($challengeId)); Cache::store('redis')->forget($this->otpKey($challengeId));
Log::error('FindId SMS send failed', [ Log::error('Admin login SMS send failed', [
'phone' => $phoneDigits, 'admin_id' => (int)$admin->id,
'error' => $ok, 'to_last4' => substr($phoneDigits, -4),
]); ]);
return ['state' => 'sms_error']; return ['state' => 'sms_error'];
} }
return [ return [
@ -102,7 +128,8 @@ final class AdminAuthService
Log::error('[admin-auth] sms send exception', [ Log::error('[admin-auth] sms send exception', [
'challenge_id' => $challengeId, 'challenge_id' => $challengeId,
'to' => substr($phoneDigits, -4), // 민감정보 최소화 'admin_id' => (int)$admin->id,
'to_last4' => substr($phoneDigits, -4),
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
@ -110,6 +137,7 @@ final class AdminAuthService
} }
} }
/** /**
* return: * return:
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked'] * - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked']

View File

@ -0,0 +1,209 @@
<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
final class AdminMeService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function decryptPhoneForMe($adminUser): string
{
$enc = (string) ($adminUser->phone_enc ?? '');
if ($enc === '') return '';
try {
// 최근 방식(encryptString) 우선
$v = Crypt::decryptString($enc);
} catch (\Throwable) {
try {
// 과거 방식(encrypt) 호환: decrypt()는 unserialize까지 해줌
$v = Crypt::decrypt($enc);
} catch (\Throwable) {
return '';
}
}
// decryptString으로 풀렸는데 serialized string이면 안전하게 한 번 풀기
if (is_string($v) && preg_match('/^s:\d+:"/', $v)) {
try {
$u = @unserialize($v, ['allowed_classes' => false]);
if (is_string($u)) return $u;
} catch (\Throwable) {
// ignore
}
}
return is_string($v) ? $v : '';
}
public function updateProfile(int $adminId, Request $request): array
{
$phone = trim((string) $request->input('phone', ''));
$data = $request->validate([
'nickname' => ['required', 'string', 'min:2', 'max:80'], // ✅ 닉네임(예: super admin)
'name' => ['required', 'string', 'min:2', 'max:80'], // ✅ 성명(본명)
'phone' => ['nullable', 'string', 'max:30'],
]);
$me = $this->repo->find($adminId);
if (!$me) {
return ['ok' => false, 'errors' => ['common' => '사용자 정보를 찾을 수 없습니다.']];
}
$before = [
'nickname' => (string)($me->nickname ?? ''),
'name' => (string)($me->name ?? ''),
'phone_last4' => $this->last4($this->decryptPhoneForMe($me)),
];
$normalized = '';
$phoneHash = null;
$phoneEnc = null;
if ($phone !== '') {
$normalized = $this->normalizeKoreanPhone($phone);
if ($normalized === '') {
return ['ok' => false, 'errors' => ['phone' => '휴대폰 번호 형식이 올바르지 않습니다.']];
}
$phoneHash = $this->makePhoneHash($normalized);
$phoneEnc = \Illuminate\Support\Facades\Crypt::encryptString($normalized);
if ($this->repo->existsPhoneHash($phoneHash, $adminId)) {
return ['ok' => false, 'errors' => ['phone' => '이미 사용 중인 휴대폰 번호입니다.']];
}
}
$payload = [
'nickname' => (string)$data['nickname'],
'name' => (string)$data['name'],
'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash,
// ✅ updated_by 컬럼이 없어도 Repository가 자동 제거함
'updated_by' => $adminId,
];
return DB::transaction(function () use ($adminId, $payload, $request, $before) {
$ok = $this->repo->updateById($adminId, $payload);
if (!$ok) {
return ['ok' => false, 'errors' => ['common' => '저장에 실패했습니다. 잠시 후 다시 시도해 주세요.']];
}
$after = [
'nickname' => (string)$payload['nickname'],
'name' => (string)$payload['name'],
'phone_last4' => $this->last4($payload['phone_enc'] ? \Illuminate\Support\Facades\Crypt::decryptString($payload['phone_enc']) : ''),
];
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.me.update',
targetType: 'admin_user',
targetId: $adminId,
before: $before,
after: $after,
ip: (string) $request->ip(),
ua: (string) $request->userAgent(),
);
return ['ok' => true, 'message' => '내 정보가 변경되었습니다.'];
});
}
public function changePassword(int $adminId, Request $request): array
{
$data = $request->validate([
'current_password' => ['required', 'string'],
'password' => ['required', 'string', 'min:10', 'max:72', 'confirmed'],
]);
$me = $this->repo->find($adminId);
if (!$me) {
return ['ok' => false, 'errors' => ['common' => '사용자 정보를 찾을 수 없습니다.']];
}
if (!Hash::check((string)$data['current_password'], (string)$me->password)) {
return ['ok' => false, 'errors' => ['current_password' => '현재 비밀번호가 일치하지 않습니다.']];
}
$before = [
'password_changed_at' => (string)($me->password_changed_at ?? ''),
];
return DB::transaction(function () use ($adminId, $data, $request, $before) {
$hash = Hash::make((string)$data['password']);
$ok = $this->repo->update($adminId, [
'password' => $hash,
'password_changed_at' => now(),
'updated_by' => $adminId,
]);
if (!$ok) {
return ['ok' => false, 'errors' => ['common' => '비밀번호 변경에 실패했습니다.']];
}
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.me.password.change',
targetType: 'admin_user',
targetId: $adminId,
before: $before,
after: ['password_changed_at' => (string)now()],
ip: (string) $request->ip(),
ua: (string) $request->userAgent(),
);
return ['ok' => true, 'message' => '비밀번호가 변경되었습니다.'];
});
}
private function normalizeKoreanPhone(string $raw): string
{
$v = preg_replace('/\D+/', '', $raw ?? '');
if (!$v) return '';
// +82 / 82 처리
if (str_starts_with($v, '82')) {
$v = '0' . substr($v, 2);
}
// 010 / 011 등 최소 10~11자리만 허용(운영 정책에 맞게 조정 가능)
if (!preg_match('/^0\d{9,10}$/', $v)) {
return '';
}
return $v;
}
private function makePhoneHash(string $normalizedPhone): string
{
$key = (string) config('security.phone_hash_key', config('app.key'));
// app.key가 base64:로 시작하면 decode
if (str_starts_with($key, 'base64:')) {
$key = base64_decode(substr($key, 7)) ?: $key;
}
return hash_hmac('sha256', $normalizedPhone, $key);
}
private function last4(string $phone): string
{
$p = preg_replace('/\D+/', '', $phone ?? '');
if (strlen($p) < 4) return '';
return substr($p, -4);
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Schema;
final class AdminUserManageService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function getAllRoles(): array
{
return $this->repo->getAllRoles(); // [id, code, name]
}
/** @return array{0:mixed,1:array,2:array} */
public function list(Request $request): array
{
$pager = $this->repo->paginateUsers([
'q' => (string)$request->query('q', ''),
'status' => (string)$request->query('status', ''),
'role' => (string)$request->query('role', ''),
], 20);
$ids = $pager->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all();
$roleMap = $this->repo->getRoleMapForUsers($ids);
$permCntMap = $this->repo->getPermissionCountMapForUsers($ids);
// last_login_ip가 binary일 수 있으니 텍스트 변환 보조값 붙이기
$pager->getCollection()->transform(function ($u) {
$u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null);
return $u;
});
return [$pager, $roleMap, $permCntMap];
}
public function editData(int $id, int $actorId): array
{
$u = $this->repo->find($id);
if (!$u) {
return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
}
$rolesAll = $this->repo->getAllRoles();
$roleIds = $this->repo->getRoleIdsForUser($id);
$roles = $this->repo->getRolesForUser($id);
$perms = $this->repo->getPermissionsForUser($id);
$u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null);
return [
'ok' => true,
'me_actor_id' => $actorId,
'user' => $u,
'rolesAll' => $rolesAll,
'roleIds' => $roleIds,
'roles' => $roles,
'perms' => $perms,
];
}
public function update(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
// 입력값(컬럼 존재 여부와 무관하게 validate는 가능)
$data = $request->validate([
'nickname' => ['required','string','min:2','max:80'],
'name' => ['required','string','min:2','max:80'],
'email' => ['required','email','max:190'],
'status' => ['nullable','string','max:20'],
'two_factor_mode' => ['nullable','string','max:20'],
'totp_enabled' => ['nullable'],
'phone' => ['nullable','string','max:30'],
'roles' => ['array'],
'roles.*' => ['integer'],
]);
// 안전장치: 자기 자신 비활성화 방지(사고 방지)
if ($actorId === $id) {
if (($data['status'] ?? '') && in_array($data['status'], ['disabled','suspended','deleted'], true)) {
return ['ok'=>false,'message'=>'본인 계정은 비활성/정지할 수 없습니다.'];
}
}
$before = $this->snapshotUser($u);
// phone 처리(선택)
$phoneDigits = '';
$phoneEnc = null;
$phoneHash = null;
$rawPhone = trim((string)($data['phone'] ?? ''));
if ($rawPhone !== '') {
$phoneDigits = $this->normalizeKoreanPhone($rawPhone);
if ($phoneDigits === '') {
return ['ok'=>false,'errors'=>['phone'=>'휴대폰 번호 형식이 올바르지 않습니다.']];
}
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') {
return ['ok'=>false,'message'=>'ADMIN_PHONE_HASH_KEY 설정이 필요합니다.'];
}
$phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey);
$phoneEnc = Crypt::encryptString($phoneDigits);
// 중복 방지(운영용) — 테스트 정책이면 여기 조건 완화 가능
if ($this->repo->existsPhoneHash($phoneHash, $id)) {
return ['ok'=>false,'errors'=>['phone'=>'이미 사용 중인 휴대폰 번호입니다.']];
}
}
$payload = [
'nickname' => (string)$data['nickname'],
'name' => (string)$data['name'],
'email' => (string)$data['email'],
'status' => (string)($data['status'] ?? $u->status ?? 'active'),
'two_factor_mode' => (string)($data['two_factor_mode'] ?? $u->two_factor_mode ?? 'sms'),
'totp_enabled' => isset($data['totp_enabled']) ? (int)!!$data['totp_enabled'] : (int)($u->totp_enabled ?? 0),
'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash,
'updated_by' => $actorId,
];
$roleIds = array_values(array_unique(array_map('intval', $data['roles'] ?? [])));
return DB::transaction(function () use ($id, $actorId, $request, $payload, $roleIds, $before) {
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return ['ok'=>false,'message'=>'저장에 실패했습니다.'];
// 역할 동기화(선택)
if (!empty($roleIds)) {
// 안전장치: 본인 super_admin 제거 금지
if ($actorId === $id) {
$cur = $this->repo->getRolesForUser($id);
$hasSuper = collect($cur)->contains(fn($r) => ($r['code'] ?? '') === 'super_admin');
if ($hasSuper) {
$superId = $this->repo->findRoleIdByCode('super_admin');
if ($superId && !in_array($superId, $roleIds, true)) {
return ['ok'=>false,'message'=>'본인 super_admin 역할은 제거할 수 없습니다.'];
}
}
}
$this->repo->syncRoles($id, $roleIds, $actorId);
}
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.update',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'관리자 정보가 저장되었습니다.'];
});
}
public function resetPasswordToEmail(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.'];
$before = $this->snapshotUser($u);
// 임시 비밀번호 = 이메일
$temp = (string)($u->email ?? '');
if ($temp === '') return ['ok'=>false,'message'=>'이메일이 없어 초기화할 수 없습니다.'];
return DB::transaction(function () use ($u, $id, $actorId, $request, $temp, $before) {
// password cast(hashed) 있으면 plain 넣어도 자동 해싱됨
$this->repo->setTemporaryPassword($u, $temp, $actorId);
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.password_reset_to_email',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'비밀번호가 이메일로 초기화되었습니다. (다음 로그인 시 변경 강제)'];
});
}
public function unlock(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.'];
$before = $this->snapshotUser($u);
$payload = [
'locked_until' => null,
'failed_login_count' => 0,
'updated_by' => $actorId,
];
return DB::transaction(function () use ($id, $actorId, $request, $payload, $before) {
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return ['ok'=>false,'message'=>'잠금 해제에 실패했습니다.'];
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.unlock',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'잠금이 해제되었습니다.'];
});
}
private function snapshotUser($u): array
{
if (!$u) return [];
return [
'id' => (int)($u->id ?? 0),
'email' => (string)($u->email ?? ''),
'nickname' => (string)($u->nickname ?? ''),
'name' => (string)($u->name ?? ''),
'status' => (string)($u->status ?? ''),
'two_factor_mode' => (string)($u->two_factor_mode ?? ''),
'totp_enabled' => (int)($u->totp_enabled ?? 0),
'locked_until' => (string)($u->locked_until ?? ''),
'last_login_at' => (string)($u->last_login_at ?? ''),
];
}
private function normalizeKoreanPhone(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '';
// 010xxxxxxxx 형태만 간단 허용(필요시 확장)
if (str_starts_with($n, '010') && strlen($n) === 11) return $n;
if (str_starts_with($n, '01') && strlen($n) >= 10 && strlen($n) <= 11) return $n;
return '';
}
private function ipToText($binOrText): string
{
if ($binOrText === null) return '-';
if (is_string($binOrText)) {
// 이미 문자열 IP면 그대로
if (str_contains($binOrText, '.') || str_contains($binOrText, ':')) return $binOrText;
// binary(4/16) 가능성
$len = strlen($binOrText);
if ($len === 4 || $len === 16) {
$t = @inet_ntop($binOrText);
return $t ?: '-';
}
}
return '-';
}
}

View File

@ -31,24 +31,24 @@ return [
'connections' => [ 'connections' => [
'default' => [ 'default' => [
'driver' => env('DB_CONNECTION', 'mysql'), 'driver' => env('DB_CONNECTION'),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST'),
'port' => env('DB_PORT', '3306'), 'port' => env('DB_PORT'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME', 'root'), 'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD', ''), 'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', 'collation' => 'utf8mb4_unicode_ci',
'strict' => true, 'strict' => true,
], ],
'sms_server' => [ 'sms_server' => [
'driver' => env('SMS_DB_CONNECTION', 'mysql'), 'driver' => env('SMS_DB_CONNECTION'),
'host' => env('SMS_DB_HOST', '127.0.0.1'), 'host' => env('SMS_DB_HOST'),
'port' => env('SMS_DB_PORT', '3306'), 'port' => env('SMS_DB_PORT'),
'database' => env('SMS_DB_DATABASE', 'lguplus'), 'database' => env('SMS_DB_DATABASE'),
'username' => env('SMS_DB_USERNAME', 'lguplus'), 'username' => env('SMS_DB_USERNAME'),
'password' => env('SMS_DB_PASSWORD', ''), 'password' => env('SMS_DB_PASSWORD'),
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', 'collation' => 'utf8mb4_unicode_ci',
'strict' => false, // 외부 DB면 strict 끄는거 OK 'strict' => false, // 외부 DB면 strict 끄는거 OK

View File

@ -6,13 +6,24 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Facades\Schema;
final class AdminRbacSeeder extends Seeder final class AdminRbacSeeder extends Seeder
{ {
/** @var array<string, array<int, string>> */
private array $colsCache = [];
public function run(): void public function run(): void
{ {
// 1) 역할(roles) // ===== 0) 테이블 존재 체크 =====
foreach (['admin_roles', 'admin_permissions', 'admin_permission_role', 'admin_users', 'admin_role_user'] as $t) {
if (!Schema::hasTable($t)) {
throw new \RuntimeException("Missing table: {$t}");
}
}
// ===== 1) 역할(roles) =====
// 현재 스키마가 name/label 기반(코드 없음)인 것에 맞춰 유지
$roles = [ $roles = [
['name' => 'super_admin', 'label' => '최고관리자'], ['name' => 'super_admin', 'label' => '최고관리자'],
['name' => 'finance', 'label' => '정산관리'], ['name' => 'finance', 'label' => '정산관리'],
@ -21,13 +32,16 @@ final class AdminRbacSeeder extends Seeder
]; ];
foreach ($roles as $r) { foreach ($roles as $r) {
$payload = ['label' => $r['label']];
$this->putTs($payload, 'admin_roles');
DB::table('admin_roles')->updateOrInsert( DB::table('admin_roles')->updateOrInsert(
['name' => $r['name']], ['name' => $r['name']],
['label' => $r['label'], 'updated_at' => now(), 'created_at' => now()] $payload
); );
} }
// 2) 권한(permissions) - 최소 셋 // ===== 2) 권한(permissions) =====
$perms = [ $perms = [
['name' => 'admin.access', 'label' => '관리자 접근'], ['name' => 'admin.access', 'label' => '관리자 접근'],
['name' => 'settlement.manage', 'label' => '정산 관리'], ['name' => 'settlement.manage', 'label' => '정산 관리'],
@ -37,96 +51,248 @@ final class AdminRbacSeeder extends Seeder
]; ];
foreach ($perms as $p) { foreach ($perms as $p) {
$payload = ['label' => $p['label']];
$this->putTs($payload, 'admin_permissions');
DB::table('admin_permissions')->updateOrInsert( DB::table('admin_permissions')->updateOrInsert(
['name' => $p['name']], ['name' => $p['name']],
['label' => $p['label'], 'updated_at' => now(), 'created_at' => now()] $payload
); );
} }
// 3) super_admin 역할에 모든 권한 부여 // ===== 3) 역할별 권한 매핑 =====
$superRoleId = (int) DB::table('admin_roles')->where('name', 'super_admin')->value('id'); // super_admin = 전체 권한
$permIds = DB::table('admin_permissions')->pluck('id')->map(fn($v) => (int)$v)->all(); // 일반 role = admin.access + 해당 도메인 권한
$roleId = fn(string $name): int => (int) DB::table('admin_roles')->where('name', $name)->value('id');
$permId = fn(string $name): int => (int) DB::table('admin_permissions')->where('name', $name)->value('id');
foreach ($permIds as $pid) { $superRoleId = $roleId('super_admin');
DB::table('admin_permission_role')->updateOrInsert([ $financeRoleId = $roleId('finance');
$productRoleId = $roleId('product');
$supportRoleId = $roleId('support');
$allPermIds = DB::table('admin_permissions')->pluck('id')->map(fn($v) => (int)$v)->all();
foreach ($allPermIds as $pid) {
$this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $pid, 'admin_permission_id' => $pid,
'admin_role_id' => $superRoleId, 'admin_role_id' => $superRoleId,
], []); ]);
} }
// 4) super_admin 유저 1명 생성(없으면) // 일반 역할 권한
$email = (string) env('ADMIN_SEED_EMAIL', 'admin@pinforyou.com'); $this->upsertPivot('admin_permission_role', [
$rawPw = (string) env('ADMIN_SEED_PASSWORD', 'ChangeMe!234'); 'admin_permission_id' => $permId('admin.access'),
$name = (string) env('ADMIN_SEED_NAME', 'Super Admin'); 'admin_role_id' => $financeRoleId,
$phone = (string) env('ADMIN_SEED_PHONE', '01012345678'); ]);
$this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $permId('settlement.manage'),
'admin_role_id' => $financeRoleId,
]);
$phoneE164 = $this->toE164Kr($phone); // +8210... $this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $permId('admin.access'),
'admin_role_id' => $productRoleId,
]);
$this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $permId('product.manage'),
'admin_role_id' => $productRoleId,
]);
$this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $permId('admin.access'),
'admin_role_id' => $supportRoleId,
]);
$this->upsertPivot('admin_permission_role', [
'admin_permission_id' => $permId('support.manage'),
'admin_role_id' => $supportRoleId,
]);
// ===== 4) super_admin 유저 1명 보장(기존 로직 유지) =====
$seedEmail = (string) env('ADMIN_SEED_EMAIL', 'sungro815@syye.net');
$seedPw = (string) env('ADMIN_SEED_PASSWORD', 'tjekdfl1324%^');
$seedName = (string) env('ADMIN_SEED_NAME', 'Super Admin');
$seedPhone = (string) env('ADMIN_SEED_PHONE', '01036828958');
$this->ensureUserWithRole(
email: $seedEmail,
passwordPlain: $seedPw,
name: $seedName,
nickname: '최고관리자',
phoneRaw: $seedPhone,
roleId: $superRoleId,
// super_admin은 phone_hash를 써도 되지만, 스키마/정책 따라 충돌 피하려면 null도 OK
usePhoneHash: true
);
// ===== 5) 일반 관리자 10명 생성 =====
$fixedPw = 'tjekdfl1324%^';
$fixedPhone = '01036828958';
$koreanNames = [
'김민준', '이서연', '박지훈', '최유진', '정현우',
'한지민', '강다은', '조성훈', '윤하늘', '오지후',
];
// 역할 배치: 1~3 finance / 4~6 product / 7~10 support
for ($i = 1; $i <= 10; $i++) {
$email = "admin{$i}@mail.com";
$name = $koreanNames[$i - 1];
if ($i <= 3) {
$rid = $financeRoleId;
$nick = "정산담당{$i}";
} elseif ($i <= 6) {
$rid = $productRoleId;
$nick = "상품담당".($i - 3);
} else {
$rid = $supportRoleId;
$nick = "CS담당".($i - 6);
}
// 일반 관리자들은 동일 전화번호 사용 → phone_hash 충돌 방지 위해 usePhoneHash=false(=NULL 저장)
$this->ensureUserWithRole(
email: $email,
passwordPlain: $fixedPw,
name: $name,
nickname: $nick,
phoneRaw: $fixedPhone,
roleId: $rid,
usePhoneHash: false
);
}
}
/**
* 유저 생성/갱신 + 역할 부여(스키마 컬럼/피벗 컬럼이 달라도 안전하게)
*/
private function ensureUserWithRole(
string $email,
string $passwordPlain,
string $name,
string $nickname,
string $phoneRaw,
int $roleId,
bool $usePhoneHash
): void {
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', '')); $hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
$phoneDigits = preg_replace('/\D+/', '', $phoneRaw) ?? '';
$phoneEnc = $phoneDigits !== '' ? Crypt::encryptString($phoneDigits) : null;
$last4 = $phoneDigits !== '' ? substr($phoneDigits, -4) : null;
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') { if ($hashKey === '') {
throw new \RuntimeException('ADMIN_PHONE_HASH_KEY (admin.phone_hash_key) is empty. Set it in .env'); throw new \RuntimeException('ADMIN_PHONE_HASH_KEY (admin.phone_hash_key) is empty. Set it in .env');
} }
$phoneHash = hash_hmac('sha256', $phoneE164, $hashKey); $phoneDigits = preg_replace('/\D+/', '', $phoneRaw) ?? '';
$phoneEnc = Crypt::encryptString($phoneE164); if ($phoneDigits === '') {
$last4 = substr(preg_replace('/\D+/', '', $phoneE164), -4) ?: null; throw new \RuntimeException('Seed phone is empty');
}
$user = DB::table('admin_users')->where('email', $email)->first(); /**
if (!$user) { * phone_hash는 NOT NULL이므로 항상 채운다.
$adminUserId = DB::table('admin_users')->insertGetId([ * - 운영 정책용(=진짜 조회키) : phoneDigits만
'email' => $email, * - seed에서 같은 번호 10 만들 : phoneDigits + email로 유니크하게
'password' => Hash::make($rawPw), */
$hashInput = $usePhoneHash
? $phoneDigits
: ($phoneDigits . '|seed|' . $email);
'name' => $name, $phoneHash = hash_hmac('sha256', $hashInput, $hashKey);
'nickname' => null,
'phone_enc' => $phoneEnc, $exists = DB::table('admin_users')->where('email', $email)->first();
'phone_hash' => $phoneHash,
'phone_last4' => $last4,
'status' => 'active', if (!$exists) {
'must_reset_password' => 1, $insert = [];
// totp는 “사용” 정책이니 enabled=1, secret은 등록 플로우에서 세팅 $this->putIfCol($insert, 'admin_users', 'email', $email);
'totp_secret_enc' => null, $this->putIfCol($insert, 'admin_users', 'password', Hash::make($passwordPlain));
'totp_enabled' => 1,
'totp_verified_at' => null,
'last_login_at' => null, $this->putIfCol($insert, 'admin_users', 'name', $name);
'last_login_ip' => null, $this->putIfCol($insert, 'admin_users', 'nickname', $nickname);
'failed_login_count' => 0,
'locked_until' => null,
'remember_token' => null, $this->putIfCol($insert, 'admin_users', 'phone_enc', $phoneEnc);
'created_at' => now(), $this->putIfCol($insert, 'admin_users', 'phone_hash', $phoneHash); // 일반 관리자는 NULL
'updated_at' => now(), $this->putIfCol($insert, 'admin_users', 'phone_last4', $last4);
'deleted_at' => null,
]);
// super_admin 역할 부여 $this->putIfCol($insert, 'admin_users', 'status', 'active');
DB::table('admin_role_user')->insert([ $this->putIfCol($insert, 'admin_users', 'must_reset_password', 0);
'admin_user_id' => $adminUserId,
'admin_role_id' => $superRoleId, $this->putIfCol($insert, 'admin_users', 'two_factor_mode', 'sms');
]); $this->putIfCol($insert, 'admin_users', 'totp_enabled', 0);
$this->putIfCol($insert, 'admin_users', 'failed_login_count', 0);
$this->putIfCol($insert, 'admin_users', 'locked_until', null);
$this->putIfCol($insert, 'admin_users', 'remember_token', null);
$this->putIfCol($insert, 'admin_users', 'deleted_at', null);
$this->putTs($insert, 'admin_users');
$adminUserId = DB::table('admin_users')->insertGetId($insert);
} else { } else {
// 이미 있으면 role만 보장 $adminUserId = (int) $exists->id;
$adminUserId = (int) $user->id;
DB::table('admin_role_user')->updateOrInsert([ // 이미 있으면 갱신(비번/이름/닉/폰)
$update = [];
$this->putIfCol($update, 'admin_users', 'password', Hash::make($passwordPlain));
$this->putIfCol($update, 'admin_users', 'name', $name);
$this->putIfCol($update, 'admin_users', 'nickname', $nickname);
$this->putIfCol($update, 'admin_users', 'phone_enc', $phoneEnc);
$this->putIfCol($update, 'admin_users', 'phone_hash', $phoneHash);
$this->putIfCol($update, 'admin_users', 'phone_last4', $last4);
$this->putIfCol($update, 'admin_users', 'status', 'active');
$this->putTs($update, 'admin_users', onlyUpdated: true);
if (!empty($update)) {
DB::table('admin_users')->where('id', $adminUserId)->update($update);
}
}
// 역할 부여
$this->upsertPivot('admin_role_user', [
'admin_user_id' => $adminUserId, 'admin_user_id' => $adminUserId,
'admin_role_id' => $superRoleId, 'admin_role_id' => $roleId,
], []); ]);
}
} }
private function toE164Kr(string $raw): string /**
* Pivot upsert (assigned_at/created_at/updated_at 존재하면 자동 채움)
*/
private function upsertPivot(string $table, array $keys): void
{ {
$n = preg_replace('/\D+/', '', $raw) ?? ''; $values = [];
if ($n === '') return '+82';
// 010xxxxxxxx 형태 -> +8210xxxxxxxx // pivot에 assigned_at 같은 NOT NULL이 있을 수 있어 자동 세팅
if (str_starts_with($n, '0')) { if ($this->hasCol($table, 'assigned_at')) $values['assigned_at'] = now();
$n = substr($n, 1); if ($this->hasCol($table, 'assigned_by')) $values['assigned_by'] = null;
if ($this->hasCol($table, 'created_at')) $values['created_at'] = now();
if ($this->hasCol($table, 'updated_at')) $values['updated_at'] = now();
DB::table($table)->updateOrInsert($keys, $values);
} }
return '+82'.$n;
private function putTs(array &$payload, string $table, bool $onlyUpdated = false): void
{
if ($this->hasCol($table, 'updated_at')) $payload['updated_at'] = now();
if (!$onlyUpdated && $this->hasCol($table, 'created_at')) $payload['created_at'] = now();
}
private function putIfCol(array &$payload, string $table, string $col, mixed $value): void
{
if ($this->hasCol($table, $col)) {
$payload[$col] = $value;
}
}
private function hasCol(string $table, string $col): bool
{
if (!isset($this->colsCache[$table])) {
$this->colsCache[$table] = Schema::getColumnListing($table);
}
return in_array($col, $this->colsCache[$table], true);
} }
} }

View File

@ -197,12 +197,19 @@ html,body{ height:100%; }
letter-spacing:.01em; letter-spacing:.01em;
} }
.a-btn--primary{ .a-btn--primary{
border:0; background: var(--a-primary);
color:white; border: 1px solid rgba(255,255,255,.12);
background: linear-gradient(135deg, var(--a-primary), var(--a-primary2)); box-shadow: none;
box-shadow: 0 14px 34px rgba(43,127,255,.18);
} }
.a-btn--primary:active{ transform: translateY(1px); }
.a-btn--primary:hover{
filter: brightness(1.05);
}
.a-btn--primary:active{
transform: translateY(1px);
}
.a-help{ margin-top:12px; } .a-help{ margin-top:12px; }
@ -210,17 +217,24 @@ html,body{ height:100%; }
.a-alert{ .a-alert{
border-radius: 14px; border-radius: 14px;
border:1px solid rgba(255,255,255,.12); border: 1px solid rgba(0,0,0,.12);
padding:12px 12px; padding: 12px 12px;
margin: 0 0 12px; margin: 0 0 12px;
background: rgba(255,255,255,.05); background: #fff; /* ✅ 흰색 배경 */
color: #111; /* ✅ 기본 검정 */
}
.a-alert__title{ font-weight:900; margin-bottom:4px; font-size:13px; color:#111; }
.a-alert__body{ font-size:13px; color:#111; }
.a-alert--danger{
border-color: rgba(255,77,79,.45);
}
.a-alert--danger .a-alert__title,
.a-alert--danger .a-alert__body{
color: #d32f2f; /* ✅ 실패 붉은색 */
} }
.a-alert__title{ font-weight:800; margin-bottom:4px; font-size:13px; }
.a-alert__body{ font-size:13px; color: rgba(255,255,255,.82); }
.a-alert--danger{ border-color: rgba(255,77,79,.30); background: rgba(255,77,79,.12); }
.a-alert--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); } .a-alert--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); }
.a-alert--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); } .a-alert--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); }
.a-brand__logoBox{ .a-brand__logoBox{
width: 110px; width: 110px;
display: inline-flex; display: inline-flex;
@ -356,6 +370,7 @@ html,body{ height:100%; }
text-decoration:none; text-decoration:none;
border: 1px solid transparent; border: 1px solid transparent;
cursor:pointer; cursor:pointer;
margin-top: 5px;
} }
.a-nav__dot{ .a-nav__dot{
@ -544,7 +559,6 @@ html,body{ height:100%; }
} }
.a-chart__placeholder{ font-size: 12px; } .a-chart__placeholder{ font-size: 12px; }
.a-table{ display:block; }
.a-tr{ .a-tr{
display:grid; display:grid;
grid-template-columns: 90px 70px 1fr 90px; grid-template-columns: 90px 70px 1fr 90px;
@ -591,3 +605,471 @@ html,body{ height:100%; }
.a-app__wrap{ grid-template-columns: 1fr; } .a-app__wrap{ grid-template-columns: 1fr; }
.a-side{ position: relative; height: auto; } .a-side{ position: relative; height: auto; }
} }
/* ===== After login layout ===== */
.a-app__wrap{ min-height:100vh; display:grid; grid-template-columns:280px 1fr; }
.a-side{ position:sticky; top:0; height:100vh; overflow:auto; border-right:1px solid var(--a-border);
background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 40%), rgba(0,0,0,.18);
backdrop-filter: blur(10px);
}
.a-side__brand{ display:flex; gap:12px; align-items:center; padding:18px 16px; border-bottom:1px solid var(--a-border); }
.a-side__logo{ width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:900;
background: linear-gradient(135deg, rgba(43,127,255,.95), rgba(124,92,255,.95)); box-shadow: var(--a-shadow2);
}
.a-nav{ padding:14px 10px 18px; }
.a-nav__title{ padding:8px 10px; font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:rgba(255,255,255,.55); }
.a-nav__item{ display:flex; gap:10px; align-items:center; padding:10px; border-radius:12px; color:rgba(255,255,255,.82);
text-decoration:none; border:1px solid transparent;
}
.a-nav__dot{ width:8px; height:8px; border-radius:999px; background:rgba(255,255,255,.25); }
.a-nav__item:hover{ background:rgba(255,255,255,.05); border-color:rgba(255,255,255,.10); }
.a-nav__item.is-active{ background: rgba(43,127,255,.12); border-color: rgba(43,127,255,.22); }
.a-nav__item.is-active .a-nav__dot{ background: linear-gradient(135deg, var(--a-primary), var(--a-primary2)); }
.a-nav__item.is-disabled{ opacity:.45; cursor:not-allowed; }
.a-main{ min-width:0; display:grid; grid-template-rows:auto 1fr auto; }
.a-top{ border-bottom:1px solid var(--a-border); background: rgba(0,0,0,.14); backdrop-filter: blur(10px); }
.a-top > *{ padding:14px 18px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
.a-top__h{ font-size:16px; font-weight:900; letter-spacing:-.02em; }
.a-top__sub{ font-size:12px; margin-top:2px; }
.a-top__user{ display:flex; gap:10px; align-items:center; border:1px solid rgba(255,255,255,.10); background:rgba(255,255,255,.04);
padding:8px 10px; border-radius:16px;
}
.a-top__avatar{ width:34px; height:34px; border-radius:12px; display:grid; place-items:center; font-weight:900;
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.10);
}
.a-top__logout{ border:1px solid rgba(255,255,255,.14); background:rgba(255,255,255,.06); color:rgba(255,255,255,.86);
padding:8px 10px; border-radius:999px; cursor:pointer; font-size:12px;
}
.a-top__logout:hover{ background:rgba(255,255,255,.10); }
.a-content{ padding:18px; }
.a-footer{ padding:14px 18px; border-top:1px solid var(--a-border); background:rgba(0,0,0,.10); }
/* ===== 내정보 화면 ===== */
.a-grid--me{ display:grid; grid-template-columns: 1fr 420px; gap:12px; }
.a-me__kv{ display:grid; grid-template-columns: 120px 1fr; gap:8px 12px; margin-top:8px; }
.a-card__title{ font-weight:900; }
.a-card__desc{ font-size:12px; margin-top:3px; }
@media (max-width: 1100px){
.a-grid--me{ grid-template-columns: 1fr; }
.a-app__wrap{ grid-template-columns: 1fr; }
.a-side{ position:relative; height:auto; }
}
/* ===== Toast (System Notification) ===== */
.a-toast-wrap{
position: fixed;
top: 18px;
right: 18px;
display: grid;
gap: 10px;
z-index: 9999;
width: min(360px, calc(100vw - 36px));
}
.a-toast{
border-radius: 14px;
border: 1px solid rgba(0,0,0,.12);
background: #fff; /* ✅ 흰색 배경 */
box-shadow: 0 12px 30px rgba(0,0,0,.18);
padding: 12px 12px;
animation: aToastIn .16s ease-out;
}
.a-toast__title{
font-weight: 900;
font-size: 13px;
margin: 0 0 4px;
color: #111; /* 기본 검정 */
}
.a-toast__msg{
font-size: 13px;
line-height: 1.35;
color: #111; /* ✅ 성공은 검정 */
}
/* ✅ 성공: 검정 유지 */
.a-toast--success{}
/* ✅ 실패(오류): 붉은색 */
.a-toast--danger{
border-color: rgba(255,77,79,.45);
}
.a-toast--danger .a-toast__title,
.a-toast--danger .a-toast__msg{
color: #d32f2f; /* ✅ 붉은색 */
}
/* (선택) warn/info도 보기 좋게 */
.a-toast--warn{ border-color: rgba(255,176,32,.45); }
.a-toast--info{ border-color: rgba(43,127,255,.35); }
@keyframes aToastIn{
from { transform: translateY(-6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* ===== Highlight button (Password Change) ===== */
.a-btn--highlight{
border: 1px solid rgba(43,127,255,.35);
background: #2b7fff; /* ✅ 단색 */
color: #fff;
box-shadow: 0 14px 34px rgba(43,127,255,.18);
}
.a-btn--highlight:hover{ filter: brightness(1.05); }
.a-btn--highlight:active{ transform: translateY(1px); }
.a-btn{
display: block; /* ✅ a 태그도 폭/레이아웃 적용 */
width:100%;
text-align:center; /* ✅ a 태그 텍스트 가운데 */
border-radius: var(--a-radius-sm);
border:1px solid rgba(255,255,255,.14);
padding:12px 14px;
cursor:pointer;
font-weight:800;
letter-spacing:.01em;
}
/* ===== Me Info (modern) ===== */
.a-meinfo{
margin-top: 10px;
display: grid;
gap: 10px;
}
.a-meinfo__row{
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
padding: 12px 12px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.03);
border-radius: 14px;
}
.a-meinfo__k{
font-size: 12px;
letter-spacing: .06em;
text-transform: uppercase;
color: rgba(255,255,255,.60);
padding-top: 2px;
}
.a-meinfo__v{
min-width: 0; /* ✅ 긴 텍스트/칩 overflow 방지 */
color: rgba(255,255,255,.92);
}
/* chips wrap */
.a-chips{
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 기존 a-chip이 있으면 덮어쓰기/보강 */
.a-chip{
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.18);
color: rgba(255,255,255,.90);
font-size: 12px;
line-height: 1;
}
.a-chip__sub{
color: rgba(255,255,255,.55);
font-size: 11px;
}
/* pills */
.a-pill{
display: inline-flex;
align-items: center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.18);
font-size: 12px;
font-weight: 800;
letter-spacing: .02em;
}
.a-pill--ok{
border-color: rgba(43,127,255,.35);
}
.a-pill--muted{
opacity: .72;
}
/* responsive */
@media (max-width: 720px){
.a-meinfo__row{
grid-template-columns: 1fr;
}
.a-meinfo__k{
text-transform: none;
letter-spacing: 0;
font-size: 13px;
color: rgba(255,255,255,.78);
}
}
/* ===== App Layout ===== */
.a-app-shell{ min-height:100vh; display:grid; grid-template-columns: 260px 1fr; }
.a-side{
border-right:1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.02);
padding: 18px;
display:flex; flex-direction:column; gap:14px;
}
.a-side__brand{ padding-bottom:10px; border-bottom:1px solid rgba(255,255,255,.08); }
.a-side__title{ font-weight:900; letter-spacing:.02em; }
.a-side__sub{ font-size:12px; margin-top:4px; }
.a-nav{ display:grid; gap:6px; }
.a-nav__sec{ margin-top:8px; font-size:12px; color:rgba(255,255,255,.55); letter-spacing:.08em; text-transform:uppercase; }
.a-nav__item{
display:block;
padding:10px 10px;
border-radius: 12px;
color: rgba(255,255,255,.86);
text-decoration:none;
border:1px solid rgba(255,255,255,.10);
background: rgba(0,0,0,.15);
}
.a-nav__item:hover{ background: rgba(255,255,255,.06); }
.a-nav__item.is-active{ border-color: rgba(43,127,255,.35); background: rgba(43,127,255,.10); }
.a-side__foot{ margin-top:auto; display:grid; gap:10px; }
.a-side__meName{ font-weight:900; }
.a-side__meMail{ font-size:12px; }
.a-main{ padding: 22px; }
/* ===== Page / Card ===== */
.a-page{ display:grid; gap:14px; }
.a-page__head{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.a-card{ border:1px solid rgba(255,255,255,.10); background: rgba(255,255,255,.03); border-radius: 18px; overflow:hidden; }
.a-card__hd{ padding:14px 16px; border-bottom:1px solid rgba(255,255,255,.08); display:flex; align-items:center; justify-content:space-between; }
.a-card__title{ font-weight:900; }
.a-card__bd{ padding:16px; }
.a-card__foot{ padding:12px 16px; border-top:1px solid rgba(255,255,255,.08); }
/* ===== Filters ===== */
.a-filters{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.a-input--sm{ padding:10px 10px; border-radius: 12px; font-size: 13px; }
.a-btn--sm{ padding:10px 12px; border-radius: 12px; font-size: 13px; }
/* ===== Table ===== */
.a-tablewrap{ overflow:auto; }
.a-table{ width:100%; border-collapse:separate; border-spacing:0; min-width: 980px; }
.a-table th, .a-table td{ padding:12px 12px; border-bottom:1px solid rgba(255,255,255,.08); vertical-align:top; }
.a-table th{ font-size:12px; color:rgba(255,255,255,.62); text-transform:uppercase; letter-spacing:.08em; }
.a-mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
/* ===== Buttons ===== */
.a-btn--ghost{
background: rgba(43,127,255,.10);
border: 1px solid rgba(43,127,255,.25);
color: rgba(255,255,255,.92);
}
.a-btn--ghost:hover{
background: rgba(43,127,255,.16);
}
.a-btn--danger{
border: 1px solid rgba(255,77,79,.35);
background: rgba(255,77,79,.14);
color: #fff;
}
/* ===== Role box ===== */
.a-rolebox{ display:grid; gap:8px; padding:10px; border:1px solid rgba(255,255,255,.10); border-radius:14px; background: rgba(0,0,0,.12); }
.a-hr{ height:1px; background: rgba(255,255,255,.08); margin:14px 0; }
/* ===== Responsive ===== */
@media (max-width: 980px){
.a-app-shell{ grid-template-columns: 1fr; }
.a-side{ position:sticky; top:0; z-index: 5; }
}
.a-grid2{ display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
@media (max-width: 980px){
.a-grid2{ grid-template-columns: 1fr; }
}
/* list page full width */
.a-content--full{
max-width: none;
width: 100%;
margin: 0;
}
/* ===== Admin list table tweaks ===== */
.a-table td{
text-align: center; /* td 가운데 */
font-size: 12.5px; /* 텍스트 조금 줄임 */
}
.a-table th{
text-align: center; /* th도 가운데로 */
font-size: 12px;
}
/* hover */
.a-table tbody tr:hover td{
background: rgba(255,255,255,.04);
}
/* blocked row style */
.a-table tr.is-disabled td{
opacity: .58; /* 연하게 */
text-decoration: line-through; /* 가운데 줄 */
}
/* status 칸만 더 또렷하게 하고 싶으면(선택) */
.a-td--status{
font-weight: 800;
letter-spacing: .02em;
}
/* admins list table hard override */
.a-content--full .a-table.a-table--admins{
width: 100% !important;
max-width: 100% !important;
table-layout: auto;
}
.a-content--full .a-table.a-table--admins th,
.a-content--full .a-table.a-table--admins td{
text-align: center !important;
font-size: 12.5px;
}
.a-content--full .a-table.a-table--admins tbody tr:hover td{
background: rgba(255,255,255,.04) !important;
}
.a-content--full .a-table.a-table--admins tr.is-disabled td{
opacity: .58;
text-decoration: line-through;
}
/* ===== Admins list table (force) ===== */
.a-content--full .a-table.a-table--admins{
width: 100% !important;
max-width: 100% !important;
border-collapse: separate;
border-spacing: 0;
}
/* center + smaller text */
.a-content--full .a-table.a-table--admins th,
.a-content--full .a-table.a-table--admins td{
text-align: center !important;
font-size: 12.5px;
padding: 10px 10px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
/* header */
.a-content--full .a-table.a-table--admins thead th{
font-size: 12px;
color: rgba(255,255,255,.72);
background: rgba(255,255,255,.03);
}
/* row hover */
.a-content--full .a-table.a-table--admins tbody tr:hover td{
background: rgba(255,255,255,.04) !important;
}
/* disabled row: pale + strike */
.a-content--full .a-table.a-table--admins tr.is-disabled td{
opacity: .58;
text-decoration: line-through;
}
/* (선택) status 칸만 또렷 */
.a-content--full .a-table.a-table--admins td.a-td--status{
font-weight: 800;
letter-spacing: .02em;
}
/* ===== Admin edit header KV grid ===== */
.a-panel__head{
display:flex;
justify-content:space-between;
align-items:flex-start;
gap:12px;
margin-bottom:12px;
}
.a-panel__title{ font-weight:900; font-size:16px; }
.a-panel__sub{ font-size:12px; margin-top:4px; }
.a-kvgrid{
display:grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap:10px;
}
.a-kv{
border: 1px solid rgba(255,255,255,.10);
background: rgba(0,0,0,.14);
border-radius: 14px;
padding: 12px 12px;
min-width:0;
}
.a-kv__k{
font-size:11px;
letter-spacing:.08em;
text-transform: uppercase;
color: rgba(255,255,255,.55);
margin-bottom:6px;
}
.a-kv__v{
font-size:13px;
font-weight:800;
color: rgba(255,255,255,.92);
word-break: break-word;
}
.a-kv__ok{ color:#111; background:#fff; border-radius:999px; padding:4px 8px; display:inline-block; }
.a-kv__danger{ color:#d32f2f; background:#fff; border-radius:999px; padding:4px 8px; display:inline-block; }
@media (max-width: 1200px){
.a-kvgrid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px){
.a-kvgrid{ grid-template-columns: 1fr; }
}
/* ===== Bottom action bar ===== */
.a-actions{
margin-top: 14px;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
flex-wrap:wrap;
}
.a-actions__right{
display:flex;
gap:8px;
flex-wrap:wrap;
justify-content:flex-end;
}
/* ===== White button (Back) ===== */
.a-btn--white{
background:#fff !important;
color:#111 !important;
border:1px solid rgba(0,0,0,.18) !important;
}
.a-btn--white:hover{ filter: brightness(0.98); }
/* ===== Solid danger button (Reset) ===== */
.a-btn--dangerSolid{
background: #ff4d4f !important;
color:#fff !important;
border: 1px solid rgba(255,255,255,.18) !important;
}
.a-btn--dangerSolid:hover{ filter: brightness(1.03); }
.a-td--status.is-bad{ color:#ff4d4f; }

View File

@ -1,4 +1,12 @@
document.addEventListener('click', (e) => { (function () {
// ✅ 스크립트가 실수로 2번 로드돼도 바인딩 1번만
if (window.__ADMIN_UI_JS_BOUND__) return;
window.__ADMIN_UI_JS_BOUND__ = true;
// -----------------------
// click handlers
// -----------------------
document.addEventListener('click', (e) => {
const t = e.target; const t = e.target;
// password toggle // password toggle
@ -11,16 +19,111 @@ document.addEventListener('click', (e) => {
t.textContent = isPw ? '숨기기' : '보기'; t.textContent = isPw ? '숨기기' : '보기';
return; return;
} }
}); });
document.addEventListener('submit', (e) => { // -----------------------
// ✅ data-confirm (ONLY ONCE)
// - capture 단계에서 먼저 실행되어
// 취소 시 다른 submit 리스너(버튼 disable 등) 실행 안 됨
// -----------------------
document.addEventListener('submit', (e) => {
const form = e.target; const form = e.target;
if (!form || !form.matches('[data-form="login"]')) return; if (!(form instanceof HTMLFormElement)) return;
const btn = form.querySelector('[data-submit]'); const raw = form.getAttribute('data-confirm');
if (btn) { if (!raw) return;
btn.disabled = true;
btn.dataset.original = btn.textContent; // 이미 confirm 통과한 submit이면 다시 confirm 금지
btn.textContent = '처리 중...'; if (form.dataset.confirmed === '1') return;
// "\n" 문자, "&#10;" 엔티티 모두 줄바꿈 처리
const msg = String(raw)
.replace(/\\n/g, '\n')
.replace(/&#10;/g, '\n');
if (!window.confirm(msg)) {
e.preventDefault();
e.stopImmediatePropagation();
return;
} }
});
form.dataset.confirmed = '1';
// confirm 통과 -> 그대로 진행 (다른 submit 리스너 정상 실행)
}, true);
// -----------------------
// login submit UI (disable + text)
// -----------------------
// document.addEventListener('submit', (e) => {
// const form = e.target;
// if (!form || !form.matches('[data-form="login"]')) return;
//
// const btn = form.querySelector('[data-submit]');
// if (btn) {
// btn.disabled = true;
// btn.dataset.original = btn.textContent;
// btn.textContent = '처리 중...';
// }
// });
// -----------------------
// showMsg (toast)
// -----------------------
if (typeof window.showMsg !== 'function') {
function ensureWrap() {
let wrap = document.getElementById('a-toast-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = 'a-toast-wrap';
wrap.className = 'a-toast-wrap';
document.body.appendChild(wrap);
}
return wrap;
}
window.showMsg = function (message, opt = {}) {
const type = opt.type || 'info'; // success | info | warn | danger
const title = opt.title || '';
const wrap = ensureWrap();
const toast = document.createElement('div');
toast.className = `a-toast a-toast--${type}`;
const t = document.createElement('div');
t.className = 'a-toast__title';
t.textContent = title || (type === 'success' ? '완료' : type === 'danger' ? '오류' : '안내');
const m = document.createElement('div');
m.className = 'a-toast__msg';
m.textContent = String(message || '');
toast.appendChild(t);
toast.appendChild(m);
wrap.appendChild(toast);
const ttl = Number(opt.ttl || 3000);
window.setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-4px)';
toast.style.transition = 'all .15s ease';
window.setTimeout(() => toast.remove(), 180);
}, ttl);
toast.addEventListener('click', () => toast.remove());
return Promise.resolve();
};
}
// flash 자동 표시
document.addEventListener('DOMContentLoaded', async () => {
const list = window.__adminFlash;
if (!Array.isArray(list) || list.length === 0) return;
for (const f of list) {
await window.showMsg(f.message, { type: f.type, title: f.title, ttl: 3200 });
}
window.__adminFlash = [];
});
})();

View File

@ -0,0 +1,72 @@
@extends('admin.layouts.app')
@section('title', '관리자 계정 등록')
@section('content')
<form method="POST" action="{{ route('admin.admins.store') }}"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
@csrf
<div class="a-panel">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">관리자 계정 등록</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
비밀번호는 입력하지 않습니다. 서버에서 임시 비밀번호를 생성하며, 다음 로그인 변경이 강제됩니다.
</div>
</div>
<a class="a-btn a-btn--ghost a-btn--sm"
href="{{ route('admin.admins.index', $filters ?? []) }}"
style="width:auto;">
목록
</a>
</div>
</div>
<div style="height:12px;"></div>
<div class="a-panel">
<div class="a-field">
<label class="a-label">이메일</label>
<input class="a-input" name="email" value="{{ old('email') }}" placeholder="admin@example.com" autocomplete="off">
@error('email') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">성명</label>
<input class="a-input" name="name" value="{{ old('name') }}" placeholder="홍길동" autocomplete="off">
@error('name') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">닉네임</label>
<input class="a-input" name="nickname" value="{{ old('nickname') }}" placeholder="정산담당1" autocomplete="off">
@error('nickname') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">휴대폰 (숫자만 10~11자리)</label>
<input class="a-input" name="phone" value="{{ old('phone') }}" placeholder="01012345678" autocomplete="off">
@error('phone') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">역할(Role)</label>
<select class="a-input" name="role">
@php $sel = old('role', 'support'); @endphp
@foreach($roles as $r)
<option value="{{ $r['code'] }}" {{ $sel===$r['code']?'selected':'' }}>
{{ $r['label'] }}
</option>
@endforeach
</select>
@error('role') <div class="a-error">{{ $message }}</div> @enderror
</div>
<button class="a-btn a-btn--primary" type="submit" style="margin-top:14px;">
등록
</button>
</div>
</form>
@endsection

View File

@ -0,0 +1,225 @@
@extends('admin.layouts.app')
@section('title', '관리자 정보 수정')
@section('content')
@php
$ip = !empty($admin->last_login_ip) ? inet_ntop($admin->last_login_ip) : '-';
$lockedUntil = $admin->locked_until ?? null;
$isLocked = false;
if (!empty($lockedUntil)) {
try {
$isLocked = \Carbon\Carbon::parse($lockedUntil)->isFuture();
} catch (\Throwable $e) {
$isLocked = true; // 파싱 실패 시 보수적으로 잠김 처리
}
}
$lockLabel = (string)($admin->locked_until ?? '');
$lockLabelLabel = $lockLabel === "" ? '계정정상' : '계정잠금';
$lockLabelColor = $lockLabel === "" ? '#2b7fff' : '#ff4d4f';
$st = (string)($admin->status ?? 'active');
$statusLabel = $st === 'active' ? '활성' : '비활성';
$statusColor = $st === 'active' ? '#2b7fff' : '#ff4d4f';
@endphp
{{-- ===== 상단 정보 패널 ===== --}}
<div class="a-kvgrid">
<div class="a-kv">
<div class="a-kv__k">관리자번호/이메일</div>
<div class="a-kv__v a-mono">{{ $admin->id ?? '-' }} / {{ $admin->email ?? '-' }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">현재 역할</div>
<div class="a-kv__v">
@forelse($roles as $rr)
<span class="a-chip">{{ $rr['name'] ?? ($rr['code'] ?? '-') }}</span>
@empty
<span class="a-muted">-</span>
@endforelse
</div>
</div>
<div class="a-kv">
<div class="a-kv__k">상태</div>
<div class="a-kv__v">
<span style="font-weight:900; color:{{ $statusColor }};">{{ $statusLabel }}</span>
@if($st !== 'active')
<span class="a-muted" style="margin-left:6px;">(관리자 로그인 불가)</span>
@endif
</div>
</div>
<div class="a-kv">
<div class="a-kv__k">계정상태</div>
<div class="a-kv__v">
<span style="font-weight:900; color:{{ $lockLabelColor }};">{{ $lockLabelLabel }}</span>
</div>
<div class="a-muted" style="font-size:12px; margin-top:10px;">
로그인 비밀번호 3 연속 실패 계정이 잠깁니다.
</div>
</div>
<div class="a-kv">
<div class="a-kv__k">로그인 실패 횟수</div>
<div class="a-kv__v">{{ (int)($admin->failed_login_count ?? 0) }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">마지막 로그인 아이피</div>
<div class="a-kv__v a-mono">{{ $ip }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">마지막 로그인 시간</div>
<div class="a-kv__v">{{ $admin->last_login_at ?? '-' }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">관리자 생성 일시</div>
<div class="a-kv__v">{{ $admin->created_at ?? '-' }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">최근 정보수정일</div>
<div class="a-kv__v">{{ $admin->updated_at ?? '-' }}</div>
</div>
<div class="a-kv">
<div class="a-kv__k">비활성화 처리자</div>
<div class="a-kv__v">{{ $admin->deleted_by ?? '-' }}</div>
</div>
</div>
<div style="height:12px;"></div>
{{-- ===== 수정 ( 하나) ===== --}}
<form id="adminEditForm"
method="POST"
action="{{ route('admin.admins.update', ['id'=>$admin->id]) }}"
onsubmit="this.querySelector('button[type=submit][data-submit=save]')?.setAttribute('disabled','disabled');">
@csrf
<div class="a-panel">
<div class="a-field">
<label class="a-label">닉네임</label>
<input class="a-input" name="nickname" value="{{ old('nickname', $admin->nickname ?? '') }}">
</div>
<div class="a-field">
<label class="a-label">성명</label>
<input class="a-input" name="name" value="{{ old('name', $admin->name ?? '') }}">
</div>
<div class="a-field">
<label class="a-label">휴대폰(01055558888 숫자만 등록하세요)</label>
<input class="a-input" name="phone" value="{{ old('phone', $phone ?? '') }}" placeholder="01012345678">
<div class="a-muted" style="font-size:12px; margin-top:6px;">
저장 phone_hash + phone_enc 갱신
</div>
</div>
<div class="a-field">
<label class="a-label">상태</label>
<select class="a-input" name="status">
@php $st = old('status', $admin->status ?? 'active'); @endphp
<option value="active" {{ $st==='active'?'selected':'' }}>활성</option>
<option value="blocked" {{ $st==='blocked'?'selected':'' }}>비활성</option>
</select>
</div>
<div class="a-field">
<label class="a-label">
2 인증방법
@if(!empty($admin->totp_secret_enc))
<span class="a-pill a-pill--ok">Google OTP 등록</span>
@else
<span class="a-pill a-pill--muted">Google OTP 미등록</span>
@endif
</label>
<select class="a-input" name="totp_enabled">
<option value="0" {{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===0 ? 'selected' : '' }}>SMS 인증</option>
<option value="1"
{{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===1 ? 'selected' : '' }}
{{ empty($admin->totp_secret_enc) ? 'disabled' : '' }}
>Google OTP 인증</option>
</select>
@if(empty($admin->totp_secret_enc))
<div class="a-muted" style="font-size:12px; margin-top:6px;">
Google OTP 미등록 상태라 선택할 없습니다. (등록은 ‘내 정보’에서만 가능)
</div>
@endif
</div>
<div class="a-field">
<label class="a-label">역할(Role)</label>
<div class="a-muted" style="font-size:12px; margin-bottom:8px;">여러 선택 가능</div>
<div style="display:flex; flex-wrap:wrap; gap:10px;">
@foreach($allRoles as $r)
@php $rid = (int)$r['id']; @endphp
<label class="a-check" style="margin:0;">
<input type="checkbox" name="role_ids[]" value="{{ $rid }}"
{{ in_array($rid, $roleIds ?? [], true) ? 'checked' : '' }}>
<span>{{ $r['name'] ?? $r['code'] }}</span>
</label>
@endforeach
</div>
</div>
</div>
</form>
{{-- ===== 하단 액션바( ) ===== --}}
<div class="a-actions">
<a class="a-btn a-btn--white a-btn--sm"
href="{{ route('admin.admins.index', request()->only(['q','status','page'])) }}"
style="width:auto;">
뒤로가기
</a>
<div class="a-actions__right">
@if($lockLabel)
<form method="POST"
action="{{ route('admin.admins.unlock', $admin->id) }}"
style="display:inline;"
data-confirm="이 계정의 잠금을 해제할까요?&#10;(locked_until 초기화 + 실패횟수 0)"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"
>
@csrf
<button class="a-btn a-btn--dangerSolid a-btn--sm" type="submit" style="width:auto;">
잠금해제
</button>
</form>
@endif
<form method="POST"
action="{{ route('admin.admins.reset_password', $admin->id) }}"
style="display:inline;"
data-confirm="비밀번호를 초기화할까요?&#10;임시 비밀번호는 이메일로 설정됩니다.&#10;(다음 로그인 시 변경 강제)"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"
>
@csrf
<button class="a-btn a-btn--dangerSolid a-btn--sm" type="submit" style="width:auto;">
비밀번호 초기화
</button>
</form>
<button class="a-btn a-btn--primary a-btn--sm"
form="adminEditForm"
type="submit"
data-submit="save"
style="width:auto;
">
저장
</button>
</div>
</div>
@endsection

View File

@ -0,0 +1,104 @@
@extends('admin.layouts.app')
@section('title', '관리자 계정 관리')
@section('content_class', 'a-content--full')
@section('content')
<div class="a-panel">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">관리자 계정 관리</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">계정/2FA/최근로그인/역할 정보를 관리합니다.</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<a class="a-btn a-btn--ghost a-btn--sm"
href="{{ route('admin.admins.create', request()->only(['q','status','page'])) }}"
style="width:auto; padding:12px 14px;">
관리자 등록
</a>
<form method="GET" action="{{ route('admin.admins.index') }}" style="display:flex; gap:8px; flex-wrap:wrap;">
<input class="a-input" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="이메일/성명/닉네임 검색" style="width:240px;">
<select class="a-input" name="status" style="width:160px;">
<option value="">상태(전체)</option>
<option value="active" {{ (($filters['status'] ?? '')==='active')?'selected':'' }}>활성 관리자</option>
<option value="blocked" {{ (($filters['status'] ?? '')==='blocked')?'selected':'' }}>비활성 관리자</option>
</select>
<button class="a-btn a-btn--primary" type="submit" style="width:auto; padding:12px 14px;">검색</button>
</form>
</div>
</div>
</div>
<div style="height:12px;"></div>
<table class="a-table a-table--admins">
<thead>
<tr>
<th style="width:80px;">ID</th>
<th>닉네임</th>
<th>성명</th>
<th>이메일</th>
<th style="width:120px;">상태</th>
<th style="width:120px;">잠금</th>
<th style="width:120px;">2FA 모드</th>
<th style="width:110px;">TOTP</th>
<th>역할</th>
<th style="width:170px;">최근 로그인</th>
<th style="width:90px;">관리</th>
</tr>
</thead>
<tbody>
@forelse($page as $u)
@php
$uid = (int)$u->id;
$roles = $roleMap[$uid] ?? [];
$pc = $permCnt[$uid] ?? 0;
@endphp
<tr class="{{ (($u->status ?? '') === 'blocked') ? 'is-disabled' : '' }}">
<td class="a-td--muted">{{ $uid }}</td>
<td>{{ $u->nickname ?? '-' }}</td>
<td>{{ $u->name ?? '-' }}</td>
<td>{{ $u->email ?? '-' }}</td>
@php
$st = (string)($u->status ?? '');
$stLabel = $st === 'active' ? '활성' : ($st === 'blocked' ? '비활성' : ($st ?: '-'));
@endphp
<td class="a-td--status {{ $st === 'blocked' ? 'is-bad' : '' }}">
{{ $stLabel }}
</td>
@php
$isLocked = !empty($u->locked_until);
@endphp
<td style="font-weight:800;">
@if($isLocked)
<span style="color:#ff4d4f;">계정잠금</span>
@else
<span style="color:rgba(255,255,255,.80);">계정정상</span>
@endif
</td>
<td>{{ $u->two_factor_mode ?? 'sms' }}</td>
<td>{{ (int)($u->totp_enabled ?? 0) === 1 ? 'On' : 'Off' }}</td>
<td>
@forelse($roles as $r)
<span class="a-chip">{{ $r['name'] ?? $r['code'] }}</span>
@empty
<span class="a-muted">-</span>
@endforelse
</td>
<td>{{ $u->last_login_at ?? '-' }}</td>
<td>
<a class="a-btn a-btn--ghost a-btn--sm" style="width:auto; padding:8px 10px;"
href="{{ route('admin.admins.edit', ['id'=>$uid]) }}">보기</a>
</td>
</tr>
@empty
<tr><td colspan="11" class="a-muted" style="padding:18px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
<div style="margin-top:14px;">
{{ $page->links() }}
</div>
@endsection

View File

@ -1,5 +1,5 @@
@extends('admin.layouts.auth') @extends('admin.layouts.auth')
@section('hide_flash', '1')
@section('title', '로그인') @section('title', '로그인')
{{-- reCAPTCHA 스크립트는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트는 페이지에서만 로드 --}}
@ -18,48 +18,30 @@
@section('content') @section('content')
<form id="loginForm" method="POST" action="{{ route('admin.login.store') }}" class="a-form" novalidate> <form id="loginForm" method="POST" action="{{ route('admin.login.store') }}" class="a-form" novalidate>
@csrf @csrf
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value=""> <input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
{{-- 에러는 상단에 1개만 --}}
@if ($errors->any())
<div class="a-alert a-alert--danger" style="margin-bottom:12px;">
<div class="a-alert__title">로그인 실패</div>
<div class="a-alert__body">{{ $errors->first() }}</div>
</div>
@endif
<div class="a-field"> <div class="a-field">
<label class="a-label" for="login_id">아이디(이메일)</label> <label class="a-label" for="login_id">아이디(이메일)</label>
<input <input class="a-input" id="login_id" name="login_id" type="text"
class="a-input" autocomplete="username" autofocus value="{{ old('login_id') }}">
id="login_id"
name="login_id"
type="text"
autocomplete="username"
autofocus
value="{{ old('login_id') }}"
>
@error('login_id')
<div class="a-error">{{ $message }}</div>
@enderror
</div> </div>
<div class="a-field"> <div class="a-field">
<label class="a-label" for="password">비밀번호</label> <label class="a-label" for="password">비밀번호</label>
<input <input class="a-input" id="password" name="password" type="password"
class="a-input" autocomplete="current-password">
id="password"
name="password"
type="password"
autocomplete="current-password"
>
@error('password')
<div class="a-error">{{ $message }}</div>
@enderror
</div> </div>
<label class="a-check" style="display:flex; gap:8px; align-items:center; margin:10px 0 0;">
<input type="checkbox" name="remember" value="1" {{ old('remember') ? 'checked' : '' }}>
<span class="a-muted">로그인 유지</span>
</label>
<button class="a-btn a-btn--primary" type="submit" style="margin-top:14px;">
로그인
</button>
<button class="a-btn a-btn--primary" type="submit" style="margin-top:14px;">로그인</button>
<div class="a-help" style="margin-top:10px;"> <div class="a-help" style="margin-top:10px;">
<small class="a-muted">로그인 성공 SMS 인증번호 입력 단계로 이동합니다.</small> <small class="a-muted">로그인 성공 SMS 인증번호 입력 단계로 이동합니다.</small>
</div> </div>
@ -170,7 +152,7 @@
form.submit(); form.submit();
} catch (err) { } catch (err) {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
await showMsgSafe('로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' }); await showMsgSafe('로그인 처리 중 오류가 발생했습니다.hhh 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' });
} }
}); });

View File

@ -1,5 +1,5 @@
@extends('admin.layouts.auth') @extends('admin.layouts.auth')
@section('hide_flash', '1')
@section('title','SMS 인증') @section('title','SMS 인증')
@section('heading', 'SMS 인증') @section('heading', 'SMS 인증')
@section('subheading', '문자로 발송된 6자리 인증번호를 입력해 주세요. (유효시간 내)') @section('subheading', '문자로 발송된 6자리 인증번호를 입력해 주세요. (유효시간 내)')

View File

@ -13,7 +13,8 @@
@include('admin.partials.topbar') @include('admin.partials.topbar')
</header> </header>
<main class="a-content"> <main class="a-content @yield('content_class')">
@include('admin.partials.flash')
@yield('content') @yield('content')
</main> </main>

View File

@ -9,7 +9,6 @@
<section class="a-auth-card" role="main"> <section class="a-auth-card" role="main">
<div class="a-auth-left"> <div class="a-auth-left">
<div class="a-brand"> <div class="a-brand">
<div class="a-brand__logoBox" aria-hidden="true"> <div class="a-brand__logoBox" aria-hidden="true">
<img <img
class="a-brand__logo" class="a-brand__logo"
@ -21,25 +20,36 @@
</div> </div>
<div class="a-brand__text"> <div class="a-brand__text">
<div class="a-brand__name">Pin For You Admin Login</div> <div class="a-brand__name">Pin For You Admin Console</div>
<div class="a-brand__sub">Secure console</div> <div class="a-brand__sub">Restricted Logged Enforced</div>
</div> </div>
</div> </div>
<div class="a-auth-left__copy"> <div class="a-auth-left__copy">
<h1 class="a-h1">@yield('heading', '관리자 로그인')</h1> <h1 class="a-h1">@yield('heading', '접근 통제 구역')</h1>
<p class="a-muted">@yield('subheading', '허용된 IP에서만 접근 가능합니다. 로그인 후 SMS 인증을 진행합니다.')</p> <p class="a-muted">
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
<br><br>
@yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
</p>
</div> </div>
<div class="a-badges"> <div class="a-badges">
<span class="a-badge">IP Allowlist</span> <span class="a-badge">IP Allowlist</span>
<span class="a-badge">2-Step (SMS)</span> <span class="a-badge">2-Step (SMS, otp)</span>
<span class="a-badge">Audit Ready</span> <span class="a-badge">Audit & Logs</span>
<span class="a-badge">Least Privilege</span>
</div> </div>
</div> </div>
<div class="a-auth-right"> <div class="a-auth-right">
@php $hideFlash = trim($__env->yieldContent('hide_flash')); @endphp
@if($hideFlash === '')
@include('admin.partials.flash') @include('admin.partials.flash')
@endif
<div class="a-panel"> <div class="a-panel">
@yield('content') @yield('content')

View File

@ -0,0 +1,47 @@
@extends('admin.layouts.app')
@section('title', '비밀번호 변경')
@section('page_title', '비밀번호 변경')
@section('page_desc', '현재 비밀번호 확인 후 변경')
@section('content')
<section class="a-page">
<article class="a-card" style="max-width:560px;">
<div class="a-card__head">
<div>
<div class="a-card__title">비밀번호 변경</div>
<div class="a-card__desc a-muted">변경 감사로그가 기록됩니다.</div>
</div>
</div>
<form method="POST" action="{{ route('admin.me.password.update') }}" class="a-form" onsubmit="this.querySelector('button[type=submit]').disabled=true;">
@csrf
<div class="a-field">
<label class="a-label" for="current_password">현재 비밀번호</label>
<input class="a-input" id="current_password" name="current_password" type="password" autocomplete="current-password">
@error('current_password')<div class="a-error">{{ $message }}</div>@enderror
</div>
<div class="a-field">
<label class="a-label" for="password"> 비밀번호</label>
<input class="a-input" id="password" name="password" type="password" autocomplete="new-password">
@error('password')<div class="a-error">{{ $message }}</div>@enderror
</div>
<div class="a-field">
<label class="a-label" for="password_confirmation"> 비밀번호 확인</label>
<input class="a-input" id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password">
</div>
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
변경
</button>
<a class="a-link" href="{{ route('admin.me') }}" style="display:inline-block; margin-top:12px;">
내정보로
</a>
</form>
</article>
</section>
@endsection

View File

@ -0,0 +1,138 @@
@extends('admin.layouts.app')
@section('title', '내 정보')
@section('page_title', '내 정보')
@section('page_desc', '프로필/연락처/보안 상태')
@section('content')
<section class="a-page">
<div class="a-grid a-grid--me">
<article class="a-card">
<div class="a-card__head">
<div>
<div class="a-card__title">기본 정보</div>
<div class="a-card__desc a-muted">이메일은 변경 불가</div>
</div>
</div>
<form method="POST" action="{{ route('admin.me.update') }}" class="a-form" onsubmit="this.querySelector('button[type=submit]').disabled=true;">
@csrf
<div class="a-field">
<label class="a-label">이메일</label>
<input class="a-input" value="{{ $me->email }}" disabled>
</div>
<div class="a-field">
<label class="a-label" for="nickname">닉네임</label>
<input
class="a-input"
id="nickname"
name="nickname"
placeholder="예: super admin"
value="{{ old('nickname', $me->nickname ?? '') }}"
>
@error('nickname')<div class="a-error">{{ $message }}</div>@enderror
</div>
<div class="a-field">
<label class="a-label" for="name">성명(본명)</label>
<input
class="a-input"
id="name"
name="name"
value="{{ old('name', $me->name ?? '') }}"
>
@error('name')<div class="a-error">{{ $message }}</div>@enderror
</div>
<div class="a-field">
<label class="a-label" for="phone">휴대폰</label>
<input class="a-input" id="phone" name="phone" placeholder="01012345678" value="{{ old('phone', $phone_plain ?? '') }}">
@error('phone')<div class="a-error">{{ $message }}</div>@enderror
</div>
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
저장
</button>
</form>
</article>
<article class="a-card">
<div class="a-card__head">
<div>
<div class="a-card__title">보안</div>
<div class="a-card__desc a-muted">비밀번호 변경 2FA 상태</div>
</div>
</div>
<div class="a-meinfo">
<div class="a-meinfo__row">
<div class="a-meinfo__k">2FA 모드</div>
<div class="a-meinfo__v">
<span class="a-pill">{{ $me->two_factor_mode ?? 'sms' }}</span>
</div>
</div>
<div class="a-meinfo__row">
<div class="a-meinfo__k">TOTP</div>
<div class="a-meinfo__v">
@if((int)($me->totp_enabled ?? 0) === 1)
<span class="a-pill a-pill--ok">Enabled</span>
@else
<span class="a-pill a-pill--muted">Disabled</span>
@endif
</div>
</div>
<div class="a-meinfo__row">
<div class="a-meinfo__k"> 역할</div>
<div class="a-meinfo__v">
<div class="a-chips">
@forelse(($roles ?? []) as $r)
<span class="a-chip">
{{ $r['name'] }}
<span class="a-chip__sub">{{ $r['code'] }}</span>
</span>
@empty
<span class="a-muted">부여된 역할이 없습니다.</span>
@endforelse
</div>
</div>
</div>
<div class="a-meinfo__row">
<div class="a-meinfo__k"> 권한</div>
<div class="a-meinfo__v">
<div class="a-chips">
@forelse(($perms ?? []) as $p)
<span class="a-chip">{{ $p['code'] }}</span>
@empty
<span class="a-muted">권한 정보가 없습니다.</span>
@endforelse
</div>
</div>
</div>
<div class="a-meinfo__row">
<div class="a-meinfo__k">최근 로그인</div>
<div class="a-meinfo__v">
{{ $me->last_login_at ? $me->last_login_at : '-' }}
</div>
</div>
</div>
<a class="a-btn a-btn--highlight" href="{{ route('admin.me.password.form') }}" style="margin-top:12px;">
비밀번호 변경
</a>
<div class="a-muted" style="font-size:12px; margin-top:10px;">
TOTP 설정/리셋은 다음 단계(권한/역할) 작업 함께 붙이는 안전합니다.
</div>
</article>
</div>
</section>
@endsection

View File

@ -1,20 +1,52 @@
@if ($errors->any()) @php
<div class="a-alert a-alert--danger" role="alert"> $toasts = [];
<div class="a-alert__title">확인해 주세요</div>
<div class="a-alert__body">{{ $errors->first() }}</div>
</div>
@endif
@if (session('status')) // 컨트롤러에서 with('toast', [...])로 보낸 경우
<div class="a-alert a-alert--info" role="status"> if (session()->has('toast')) {
<div class="a-alert__title">안내</div> $t = session('toast');
<div class="a-alert__body">{{ session('status') }}</div> if (is_array($t)) $toasts[] = $t;
</div> }
@endif
@if (session('alert')) // 라라벨 기본 status/error도 같이 지원
<div class="a-alert a-alert--warn" role="alert"> if (session()->has('status')) {
<div class="a-alert__title">알림</div> $toasts[] = ['type' => 'success', 'title' => '안내', 'message' => (string) session('status')];
<div class="a-alert__body">{{ session('alert') }}</div> }
if (session()->has('error')) {
$toasts[] = ['type' => 'danger', 'title' => '오류', 'message' => (string) session('error')];
}
// validation 에러도 토스트로 합치기(여러 줄)
if ($errors->any()) {
$toasts[] = [
'type' => 'danger',
'title' => '입력 오류',
'message' => implode("\n", $errors->all()),
];
}
@endphp
@if(!empty($toasts))
<div class="a-toast-wrap" id="aToastWrap">
@foreach($toasts as $t)
@php
$type = $t['type'] ?? 'info'; // success|danger|warn|info
$title = $t['title'] ?? '알림';
$msg = $t['message'] ?? '';
@endphp
<div class="a-toast a-toast--{{ $type }}">
<div class="a-toast__title">{{ $title }}</div>
<div class="a-toast__msg">{!! nl2br(e((string)$msg)) !!}</div>
</div> </div>
@endforeach
</div>
<script>
(function(){
const wrap = document.getElementById('aToastWrap');
if(!wrap) return;
setTimeout(() => {
wrap.querySelectorAll('.a-toast').forEach(el => el.remove());
}, 3500);
})();
</script>
@endif @endif

View File

@ -81,14 +81,6 @@
]; ];
@endphp @endphp
<div class="a-side__brand">
<div class="a-side__logo">A</div>
<div class="a-side__brandText">
<div class="a-side__brandName">Admin</div>
<div class="a-side__brandSub">Pin For You</div>
</div>
</div>
<nav class="a-nav"> <nav class="a-nav">
@foreach($menu as $group) @foreach($menu as $group)
<div class="a-nav__group"> <div class="a-nav__group">
@ -96,12 +88,13 @@
@foreach($group['items'] as $it) @foreach($group['items'] as $it)
@php @php
$isActive = \Illuminate\Support\Facades\Route::has($it['route']) $has = \Illuminate\Support\Facades\Route::has($it['route']);
? request()->routeIs($it['route']) // index면 admin.admins.* 전체를 active로 잡아줌
: false; $base = preg_replace('/\.index$/', '', $it['route']);
$isActive = $has ? request()->routeIs($base.'*') : false;
@endphp @endphp
@if(\Illuminate\Support\Facades\Route::has($it['route'])) @if($has)
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($it['route']) }}"> <a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($it['route']) }}">
<span class="a-nav__dot" aria-hidden="true"></span> <span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span> <span class="a-nav__label">{{ $it['label'] }}</span>

View File

@ -1,22 +1,17 @@
<div class="a-top__left"> <div class="a-top__left">
<div class="a-top__title"> <div class="a-top__title">
<div class="a-top__h">대시보드</div> <div class="a-top__h">@yield('page_title', '콘솔')</div>
<div class="a-top__sub a-muted">가입/로그인/문의/매출 흐름을 한눈에 확인</div> <div class="a-top__sub a-muted">@yield('page_desc', '관리자 콘솔')</div>
</div> </div>
</div> </div>
<div class="a-top__right"> <div class="a-top__right">
<form class="a-top__search" action="#" method="get" onsubmit="return false;">
<input class="a-top__searchInput" placeholder="검색 (회원/주문/핀/문의)" />
</form>
<div class="a-top__user"> <div class="a-top__user">
<div class="a-top__avatar" aria-hidden="true"> @php $u = auth('admin')->user(); @endphp
{{ mb_substr(auth('admin')->user()->name ?? 'A', 0, 1) }} <div class="a-top__userName">{{ ($u->nickname ?? '') !== '' ? $u->nickname : ($u->name ?? 'Admin') }}</div>
</div> <div class="a-top__userMeta a-muted">{{ $u->email ?? '' }}</div>
<div class="a-top__userText"> <div class="a-top__userText">
<div class="a-top__userName">{{ auth('admin')->user()->name ?? 'Admin' }}</div> <div class="a-top__userName">{{ auth('admin')->user()->name ?? 'Admin' }}</div>
<div class="a-top__userMeta a-muted">{{ auth('admin')->user()->email ?? '' }}</div>
</div> </div>
<form method="POST" action="{{ route('admin.logout') }}"> <form method="POST" action="{{ route('admin.logout') }}">

View File

@ -1,11 +1,13 @@
<?php <?php
use App\Http\Controllers\Admin\Auth\AdminAuthController; use App\Http\Controllers\Admin\Auth\AdminAuthController;
use App\Http\Controllers\Admin\AdminAdminsController;
use App\Http\Controllers\Admin\MeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () { Route::middleware(['web'])->group(function () {
// 로그인/OTP/비번초기화는 guest:admin 만 접근 // 로그인/OTP/비번초기화는 guest:admin 만 접근
Route::middleware('guest:admin')->group(function () { Route::middleware('guest:admin')->group(function () {
Route::get('/login', [AdminAuthController::class, 'showLogin']) Route::get('/login', [AdminAuthController::class, 'showLogin'])
@ -30,15 +32,35 @@ Route::middleware(['web'])->group(function () {
->name('admin.otp.store'); ->name('admin.otp.store');
}); });
// 로그인 이후 // 로그인 이후
Route::middleware('auth:admin')->group(function () { Route::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () {
Route::get('/', fn() => view('admin.home'))->name('admin.home'); Route::get('/', fn() => view('admin.home'))->name('admin.home');
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
Route::post('/logout', [AdminAuthController::class, 'logout']) Route::post('/logout', [AdminAuthController::class, 'logout'])
->name('admin.logout'); ->name('admin.logout');
Route::prefix('/admins')->name('admin.admins.')->group(function () {
Route::get('/', [AdminAdminsController::class, 'index'])->name('index');
Route::get('/create', [AdminAdminsController::class, 'create'])->name('create');
Route::post('/', [AdminAdminsController::class, 'store'])->name('store');
Route::get('/{id}', [AdminAdminsController::class, 'edit'])->name('edit');
Route::post('/{id}', [AdminAdminsController::class, 'update'])->name('update');
Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password');
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
}); });
});
}); });
/* 개발용 페이지 세션 보기 */ /* 개발용 페이지 세션 보기 */
if (config('app.debug') || app()->environment('local')) { if (config('app.debug') || app()->environment('local')) {
require __DIR__.'/dev_admin.php'; require __DIR__.'/dev_admin.php';