관리자 회원 관리 , 등록 , 수정, 권한변경 작업완료
This commit is contained in:
parent
0010cc69be
commit
722b1b8575
150
app/Http/Controllers/Admin/AdminAdminsController.php
Normal file
150
app/Http/Controllers/Admin/AdminAdminsController.php
Normal 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} (다음 로그인 시 변경 강제)"
|
||||
: "관리자 계정이 등록되었습니다. (다음 로그인 시 비밀번호 변경 강제)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -23,12 +23,11 @@ final class AdminAuthController extends Controller
|
||||
public function storeLogin(Request $request)
|
||||
{
|
||||
$rules = [
|
||||
'login_id' => ['required', 'string', 'max:190'], // admin_users.email(190)
|
||||
'login_id' => ['required', 'string', 'max:190'],
|
||||
'password' => ['required', 'string', 'max:255'],
|
||||
'remember' => ['nullable'],
|
||||
];
|
||||
|
||||
// 운영에서만 reCAPTCHA 필수
|
||||
if (app()->environment('production')) {
|
||||
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('admin_login')];
|
||||
}
|
||||
@ -46,46 +45,116 @@ final class AdminAuthController extends Controller
|
||||
ip: $ip
|
||||
);
|
||||
|
||||
if (($res['state'] ?? '') === 'invalid') {
|
||||
return back()->withErrors(['login_id' => '이메일 또는 비밀번호를 확인하세요.'])->withInput();
|
||||
$state = (string) ($res['state'] ?? '');
|
||||
|
||||
// ✅ 1) 계정 잠김
|
||||
if ($state === 'locked') {
|
||||
$msg = '계정이 잠금 상태입니다. 최고관리자에게 잠금 해제를 요청해 주세요.';
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['login_id' => $msg])
|
||||
->with('toast', [
|
||||
'type' => 'danger',
|
||||
'title' => '계정 잠김',
|
||||
'message' => $msg,
|
||||
]);
|
||||
}
|
||||
|
||||
if (($res['state'] ?? '') === 'sms_error') {
|
||||
return back()->withErrors(['login_id' => '인증 sms 발송에 실패하였습니다.'])->withInput();
|
||||
// ✅ 2) 비번 불일치/계정없음 (남은 시도 횟수 포함)
|
||||
if ($state === 'invalid') {
|
||||
$left = $res['attempts_left'] ?? null;
|
||||
|
||||
$msg = '이메일 또는 비밀번호를 확인하세요.';
|
||||
if ($left !== null && is_numeric($left)) {
|
||||
$msg .= ' (남은 시도 ' . (int)$left . '회)';
|
||||
}
|
||||
|
||||
if (($res['state'] ?? '') === 'blocked') {
|
||||
return back()->withErrors(['login_id' => '로그인 할 수 없는 계정입니다.'])->withInput();
|
||||
return back()
|
||||
->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', [
|
||||
'admin_id' => (int) ($res['admin_id'] ?? 0),
|
||||
'email' => $email,
|
||||
'expires_at' => time() + 600, // 10분
|
||||
'expires_at' => time() + 600,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.password.reset.form')
|
||||
->with('status', '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.');
|
||||
return redirect()
|
||||
->route('admin.password.reset.form')
|
||||
->with('toast', [
|
||||
'type' => 'info',
|
||||
'title' => '비밀번호 변경 필요',
|
||||
'message' => '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (($res['state'] ?? '') !== 'otp_sent') {
|
||||
// 방어: 예상치 못한 상태
|
||||
return back()->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])->withInput();
|
||||
}
|
||||
|
||||
// ✅ OTP 챌린지 ID만 세션에 보관 (OTP 평문/해시 세션 저장 X)
|
||||
// ✅ 6) OTP 발송 성공
|
||||
if ($state === 'otp_sent') {
|
||||
$request->session()->put('admin_2fa', [
|
||||
'challenge_id' => (string) ($res['challenge_id'] ?? ''),
|
||||
'masked_phone' => (string) ($res['masked_phone'] ?? ''),
|
||||
'expires_at' => time() + (int) config('admin.sms_ttl', 180),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.otp.form')
|
||||
->with('status', '인증번호를 문자로 발송했습니다.');
|
||||
return redirect()
|
||||
->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)
|
||||
{
|
||||
|
||||
91
app/Http/Controllers/Admin/MeController.php
Normal file
91
app/Http/Controllers/Admin/MeController.php
Normal 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' => '변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
app/Http/Middleware/NoStore.php
Normal file
16
app/Http/Middleware/NoStore.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -30,4 +30,16 @@ class AdminUser extends Authenticatable
|
||||
'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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -3,9 +3,21 @@
|
||||
namespace App\Repositories\Admin;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
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
|
||||
{
|
||||
return AdminUser::query()
|
||||
@ -21,18 +33,457 @@ final class AdminUserRepository
|
||||
->first();
|
||||
}
|
||||
|
||||
public function touchLogin(AdminUser $admin, string $ip): void
|
||||
public function find(int $id): ?object
|
||||
{
|
||||
$admin->last_login_at = now();
|
||||
$admin->last_login_ip = inet_pton($ip) ?: null;
|
||||
$admin->save();
|
||||
return DB::table('admin_users')->where('id', $id)->first();
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 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
|
||||
{
|
||||
$admin->password = $plainPassword; // ✅ 캐스트가 알아서 해싱함
|
||||
$admin->must_reset_password = 0;
|
||||
// ✅ AdminUser 모델에 password cast(hashed)가 있으면 plain을 넣어도 해싱됨
|
||||
$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();
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
306
app/Services/Admin/AdminAdminsService.php
Normal file
306
app/Services/Admin/AdminAdminsService.php
Normal 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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/Services/Admin/AdminAuditService.php
Normal file
31
app/Services/Admin/AdminAuditService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -29,15 +29,41 @@ final class AdminAuthService
|
||||
{
|
||||
$admin = $this->users->findByEmail($email);
|
||||
|
||||
// 계정 없거나 비번 불일치: 같은 메시지로
|
||||
if (!$admin || !Hash::check($password, (string)$admin->password)) {
|
||||
// ✅ 계정 없으면 invalid (계정 존재 여부 노출 방지)
|
||||
if (!$admin) {
|
||||
return ['state' => 'invalid'];
|
||||
}
|
||||
|
||||
// ✅ 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자)
|
||||
if (($admin->status ?? 'blocked') !== 'active') {
|
||||
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) {
|
||||
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
|
||||
}
|
||||
@ -45,6 +71,7 @@ final class AdminAuthService
|
||||
// phone_enc 복호화(E164 or digits)
|
||||
$phoneDigits = $this->decryptPhoneToDigits($admin);
|
||||
if ($phoneDigits === '') {
|
||||
// 내부 데이터 문제라서 카운트 올리진 않음(원하면 올려도 됨)
|
||||
return ['state' => 'invalid'];
|
||||
}
|
||||
|
||||
@ -84,12 +111,11 @@ final class AdminAuthService
|
||||
|
||||
if (!$ok) {
|
||||
Cache::store('redis')->forget($this->otpKey($challengeId));
|
||||
Log::error('FindId SMS send failed', [
|
||||
'phone' => $phoneDigits,
|
||||
'error' => $ok,
|
||||
Log::error('Admin login SMS send failed', [
|
||||
'admin_id' => (int)$admin->id,
|
||||
'to_last4' => substr($phoneDigits, -4),
|
||||
]);
|
||||
return ['state' => 'sms_error'];
|
||||
|
||||
}
|
||||
|
||||
return [
|
||||
@ -102,7 +128,8 @@ final class AdminAuthService
|
||||
|
||||
Log::error('[admin-auth] sms send exception', [
|
||||
'challenge_id' => $challengeId,
|
||||
'to' => substr($phoneDigits, -4), // 민감정보 최소화
|
||||
'admin_id' => (int)$admin->id,
|
||||
'to_last4' => substr($phoneDigits, -4),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
@ -110,6 +137,7 @@ final class AdminAuthService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* return:
|
||||
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked']
|
||||
|
||||
209
app/Services/Admin/AdminMeService.php
Normal file
209
app/Services/Admin/AdminMeService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
295
app/Services/Admin/AdminUserManageService.php
Normal file
295
app/Services/Admin/AdminUserManageService.php
Normal 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 '-';
|
||||
}
|
||||
}
|
||||
@ -31,24 +31,24 @@ return [
|
||||
'connections' => [
|
||||
|
||||
'default' => [
|
||||
'driver' => env('DB_CONNECTION', 'mysql'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'driver' => env('DB_CONNECTION'),
|
||||
'host' => env('DB_HOST'),
|
||||
'port' => env('DB_PORT'),
|
||||
'database' => env('DB_DATABASE'),
|
||||
'username' => env('DB_USERNAME'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'strict' => true,
|
||||
],
|
||||
|
||||
'sms_server' => [
|
||||
'driver' => env('SMS_DB_CONNECTION', 'mysql'),
|
||||
'host' => env('SMS_DB_HOST', '127.0.0.1'),
|
||||
'port' => env('SMS_DB_PORT', '3306'),
|
||||
'database' => env('SMS_DB_DATABASE', 'lguplus'),
|
||||
'username' => env('SMS_DB_USERNAME', 'lguplus'),
|
||||
'password' => env('SMS_DB_PASSWORD', ''),
|
||||
'driver' => env('SMS_DB_CONNECTION'),
|
||||
'host' => env('SMS_DB_HOST'),
|
||||
'port' => env('SMS_DB_PORT'),
|
||||
'database' => env('SMS_DB_DATABASE'),
|
||||
'username' => env('SMS_DB_USERNAME'),
|
||||
'password' => env('SMS_DB_PASSWORD'),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'strict' => false, // 외부 DB면 strict 끄는거 OK
|
||||
|
||||
@ -6,13 +6,24 @@ use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class AdminRbacSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $colsCache = [];
|
||||
|
||||
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 = [
|
||||
['name' => 'super_admin', 'label' => '최고관리자'],
|
||||
['name' => 'finance', 'label' => '정산관리'],
|
||||
@ -21,13 +32,16 @@ final class AdminRbacSeeder extends Seeder
|
||||
];
|
||||
|
||||
foreach ($roles as $r) {
|
||||
$payload = ['label' => $r['label']];
|
||||
$this->putTs($payload, 'admin_roles');
|
||||
|
||||
DB::table('admin_roles')->updateOrInsert(
|
||||
['name' => $r['name']],
|
||||
['label' => $r['label'], 'updated_at' => now(), 'created_at' => now()]
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 권한(permissions) - 최소 셋
|
||||
// ===== 2) 권한(permissions) =====
|
||||
$perms = [
|
||||
['name' => 'admin.access', 'label' => '관리자 접근'],
|
||||
['name' => 'settlement.manage', 'label' => '정산 관리'],
|
||||
@ -37,96 +51,248 @@ final class AdminRbacSeeder extends Seeder
|
||||
];
|
||||
|
||||
foreach ($perms as $p) {
|
||||
$payload = ['label' => $p['label']];
|
||||
$this->putTs($payload, 'admin_permissions');
|
||||
|
||||
DB::table('admin_permissions')->updateOrInsert(
|
||||
['name' => $p['name']],
|
||||
['label' => $p['label'], 'updated_at' => now(), 'created_at' => now()]
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
// 3) super_admin 역할에 모든 권한 부여
|
||||
$superRoleId = (int) DB::table('admin_roles')->where('name', 'super_admin')->value('id');
|
||||
$permIds = DB::table('admin_permissions')->pluck('id')->map(fn($v) => (int)$v)->all();
|
||||
// ===== 3) 역할별 권한 매핑 =====
|
||||
// super_admin = 전체 권한
|
||||
// 일반 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) {
|
||||
DB::table('admin_permission_role')->updateOrInsert([
|
||||
$superRoleId = $roleId('super_admin');
|
||||
$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_role_id' => $superRoleId,
|
||||
], []);
|
||||
]);
|
||||
}
|
||||
|
||||
// 4) super_admin 유저 1명 생성(없으면)
|
||||
$email = (string) env('ADMIN_SEED_EMAIL', 'admin@pinforyou.com');
|
||||
$rawPw = (string) env('ADMIN_SEED_PASSWORD', 'ChangeMe!234');
|
||||
$name = (string) env('ADMIN_SEED_NAME', 'Super Admin');
|
||||
$phone = (string) env('ADMIN_SEED_PHONE', '01012345678');
|
||||
// 일반 역할 권한
|
||||
$this->upsertPivot('admin_permission_role', [
|
||||
'admin_permission_id' => $permId('admin.access'),
|
||||
'admin_role_id' => $financeRoleId,
|
||||
]);
|
||||
$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', ''));
|
||||
|
||||
$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 === '') {
|
||||
throw new \RuntimeException('ADMIN_PHONE_HASH_KEY (admin.phone_hash_key) is empty. Set it in .env');
|
||||
}
|
||||
|
||||
$phoneHash = hash_hmac('sha256', $phoneE164, $hashKey);
|
||||
$phoneEnc = Crypt::encryptString($phoneE164);
|
||||
$last4 = substr(preg_replace('/\D+/', '', $phoneE164), -4) ?: null;
|
||||
$phoneDigits = preg_replace('/\D+/', '', $phoneRaw) ?? '';
|
||||
if ($phoneDigits === '') {
|
||||
throw new \RuntimeException('Seed phone is empty');
|
||||
}
|
||||
|
||||
$user = DB::table('admin_users')->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
$adminUserId = DB::table('admin_users')->insertGetId([
|
||||
'email' => $email,
|
||||
'password' => Hash::make($rawPw),
|
||||
/**
|
||||
* ✅ phone_hash는 NOT NULL이므로 항상 채운다.
|
||||
* - 운영 정책용(=진짜 조회키)일 때: phoneDigits만
|
||||
* - seed에서 같은 번호 10명 만들 때: phoneDigits + email로 유니크하게
|
||||
*/
|
||||
$hashInput = $usePhoneHash
|
||||
? $phoneDigits
|
||||
: ($phoneDigits . '|seed|' . $email);
|
||||
|
||||
'name' => $name,
|
||||
'nickname' => null,
|
||||
$phoneHash = hash_hmac('sha256', $hashInput, $hashKey);
|
||||
|
||||
'phone_enc' => $phoneEnc,
|
||||
'phone_hash' => $phoneHash,
|
||||
'phone_last4' => $last4,
|
||||
$exists = DB::table('admin_users')->where('email', $email)->first();
|
||||
|
||||
'status' => 'active',
|
||||
'must_reset_password' => 1,
|
||||
if (!$exists) {
|
||||
$insert = [];
|
||||
|
||||
// totp는 “사용” 정책이니 enabled=1, secret은 등록 플로우에서 세팅
|
||||
'totp_secret_enc' => null,
|
||||
'totp_enabled' => 1,
|
||||
'totp_verified_at' => null,
|
||||
$this->putIfCol($insert, 'admin_users', 'email', $email);
|
||||
$this->putIfCol($insert, 'admin_users', 'password', Hash::make($passwordPlain));
|
||||
|
||||
'last_login_at' => null,
|
||||
'last_login_ip' => null,
|
||||
'failed_login_count' => 0,
|
||||
'locked_until' => null,
|
||||
$this->putIfCol($insert, 'admin_users', 'name', $name);
|
||||
$this->putIfCol($insert, 'admin_users', 'nickname', $nickname);
|
||||
|
||||
'remember_token' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->putIfCol($insert, 'admin_users', 'phone_enc', $phoneEnc);
|
||||
$this->putIfCol($insert, 'admin_users', 'phone_hash', $phoneHash); // 일반 관리자는 NULL
|
||||
$this->putIfCol($insert, 'admin_users', 'phone_last4', $last4);
|
||||
|
||||
// super_admin 역할 부여
|
||||
DB::table('admin_role_user')->insert([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'admin_role_id' => $superRoleId,
|
||||
]);
|
||||
$this->putIfCol($insert, 'admin_users', 'status', 'active');
|
||||
$this->putIfCol($insert, 'admin_users', 'must_reset_password', 0);
|
||||
|
||||
$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 {
|
||||
// 이미 있으면 role만 보장
|
||||
$adminUserId = (int) $user->id;
|
||||
DB::table('admin_role_user')->updateOrInsert([
|
||||
$adminUserId = (int) $exists->id;
|
||||
|
||||
// 이미 있으면 갱신(비번/이름/닉/폰)
|
||||
$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_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) ?? '';
|
||||
if ($n === '') return '+82';
|
||||
$values = [];
|
||||
|
||||
// 010xxxxxxxx 형태 -> +8210xxxxxxxx
|
||||
if (str_starts_with($n, '0')) {
|
||||
$n = substr($n, 1);
|
||||
// pivot에 assigned_at 같은 NOT NULL이 있을 수 있어 자동 세팅
|
||||
if ($this->hasCol($table, 'assigned_at')) $values['assigned_at'] = now();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,12 +197,19 @@ html,body{ height:100%; }
|
||||
letter-spacing:.01em;
|
||||
}
|
||||
.a-btn--primary{
|
||||
border:0;
|
||||
color:white;
|
||||
background: linear-gradient(135deg, var(--a-primary), var(--a-primary2));
|
||||
box-shadow: 0 14px 34px rgba(43,127,255,.18);
|
||||
background: var(--a-primary);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
.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; }
|
||||
|
||||
@ -210,17 +217,24 @@ html,body{ height:100%; }
|
||||
|
||||
.a-alert{
|
||||
border-radius: 14px;
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 12px 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--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); }
|
||||
|
||||
.a-brand__logoBox{
|
||||
width: 110px;
|
||||
display: inline-flex;
|
||||
@ -356,6 +370,7 @@ html,body{ height:100%; }
|
||||
text-decoration:none;
|
||||
border: 1px solid transparent;
|
||||
cursor:pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.a-nav__dot{
|
||||
@ -544,7 +559,6 @@ html,body{ height:100%; }
|
||||
}
|
||||
.a-chart__placeholder{ font-size: 12px; }
|
||||
|
||||
.a-table{ display:block; }
|
||||
.a-tr{
|
||||
display:grid;
|
||||
grid-template-columns: 90px 70px 1fr 90px;
|
||||
@ -591,3 +605,471 @@ html,body{ height:100%; }
|
||||
.a-app__wrap{ grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
(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;
|
||||
|
||||
@ -13,14 +21,109 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------
|
||||
// ✅ data-confirm (ONLY ONCE)
|
||||
// - capture 단계에서 먼저 실행되어
|
||||
// 취소 시 다른 submit 리스너(버튼 disable 등) 실행 안 됨
|
||||
// -----------------------
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target;
|
||||
if (!form || !form.matches('[data-form="login"]')) return;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
|
||||
const btn = form.querySelector('[data-submit]');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.dataset.original = btn.textContent;
|
||||
btn.textContent = '처리 중...';
|
||||
const raw = form.getAttribute('data-confirm');
|
||||
if (!raw) return;
|
||||
|
||||
// 이미 confirm 통과한 submit이면 다시 confirm 금지
|
||||
if (form.dataset.confirmed === '1') return;
|
||||
|
||||
// "\n" 문자, " " 엔티티 모두 줄바꿈 처리
|
||||
const msg = String(raw)
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/ /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 = [];
|
||||
});
|
||||
})();
|
||||
|
||||
72
resources/views/admin/admins/create.blade.php
Normal file
72
resources/views/admin/admins/create.blade.php
Normal 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
|
||||
225
resources/views/admin/admins/edit.blade.php
Normal file
225
resources/views/admin/admins/edit.blade.php
Normal 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="이 계정의 잠금을 해제할까요? (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="비밀번호를 초기화할까요? 임시 비밀번호는 이메일로 설정됩니다. (다음 로그인 시 변경 강제)"
|
||||
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
|
||||
104
resources/views/admin/admins/index.blade.php
Normal file
104
resources/views/admin/admins/index.blade.php
Normal 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
|
||||
@ -1,5 +1,5 @@
|
||||
@extends('admin.layouts.auth')
|
||||
|
||||
@section('hide_flash', '1')
|
||||
@section('title', '로그인')
|
||||
|
||||
{{-- ✅ reCAPTCHA 스크립트는 이 페이지에서만 로드 --}}
|
||||
@ -18,48 +18,30 @@
|
||||
@section('content')
|
||||
<form id="loginForm" method="POST" action="{{ route('admin.login.store') }}" class="a-form" novalidate>
|
||||
@csrf
|
||||
|
||||
<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">
|
||||
<label class="a-label" for="login_id">아이디(이메일)</label>
|
||||
<input
|
||||
class="a-input"
|
||||
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
|
||||
<input class="a-input" id="login_id" name="login_id" type="text"
|
||||
autocomplete="username" autofocus value="{{ old('login_id') }}">
|
||||
</div>
|
||||
|
||||
<div class="a-field">
|
||||
<label class="a-label" for="password">비밀번호</label>
|
||||
<input
|
||||
class="a-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
@error('password')
|
||||
<div class="a-error">{{ $message }}</div>
|
||||
@enderror
|
||||
<input class="a-input" id="password" name="password" type="password"
|
||||
autocomplete="current-password">
|
||||
</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;">
|
||||
<small class="a-muted">로그인 성공 후 SMS 인증번호 입력 단계로 이동합니다.</small>
|
||||
</div>
|
||||
@ -170,7 +152,7 @@
|
||||
form.submit();
|
||||
} catch (err) {
|
||||
if (btn) btn.disabled = false;
|
||||
await showMsgSafe('로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' });
|
||||
await showMsgSafe('로그인 처리 중 오류가 발생했습니다.hhh 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@extends('admin.layouts.auth')
|
||||
|
||||
@section('hide_flash', '1')
|
||||
@section('title','SMS 인증')
|
||||
@section('heading', 'SMS 인증')
|
||||
@section('subheading', '문자로 발송된 6자리 인증번호를 입력해 주세요. (유효시간 내)')
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
@include('admin.partials.topbar')
|
||||
</header>
|
||||
|
||||
<main class="a-content">
|
||||
<main class="a-content @yield('content_class')">
|
||||
@include('admin.partials.flash')
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
<section class="a-auth-card" role="main">
|
||||
<div class="a-auth-left">
|
||||
<div class="a-brand">
|
||||
|
||||
<div class="a-brand__logoBox" aria-hidden="true">
|
||||
<img
|
||||
class="a-brand__logo"
|
||||
@ -21,25 +20,36 @@
|
||||
</div>
|
||||
|
||||
<div class="a-brand__text">
|
||||
<div class="a-brand__name">Pin For You Admin Login</div>
|
||||
<div class="a-brand__sub">Secure console</div>
|
||||
<div class="a-brand__name">Pin For You Admin Console</div>
|
||||
<div class="a-brand__sub">Restricted • Logged • Enforced</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a-auth-left__copy">
|
||||
<h1 class="a-h1">@yield('heading', '관리자 로그인')</h1>
|
||||
<p class="a-muted">@yield('subheading', '허용된 IP에서만 접근 가능합니다. 로그인 후 SMS 인증을 진행합니다.')</p>
|
||||
<h1 class="a-h1">@yield('heading', '접근 통제 구역')</h1>
|
||||
<p class="a-muted">
|
||||
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
|
||||
<br><br>
|
||||
@yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="a-badges">
|
||||
<span class="a-badge">IP Allowlist</span>
|
||||
<span class="a-badge">2-Step (SMS)</span>
|
||||
<span class="a-badge">Audit Ready</span>
|
||||
<span class="a-badge">2-Step (SMS, otp)</span>
|
||||
<span class="a-badge">Audit & Logs</span>
|
||||
<span class="a-badge">Least Privilege</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="a-auth-right">
|
||||
@php $hideFlash = trim($__env->yieldContent('hide_flash')); @endphp
|
||||
@if($hideFlash === '')
|
||||
@include('admin.partials.flash')
|
||||
@endif
|
||||
|
||||
|
||||
<div class="a-panel">
|
||||
@yield('content')
|
||||
|
||||
47
resources/views/admin/me/password.blade.php
Normal file
47
resources/views/admin/me/password.blade.php
Normal 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
|
||||
138
resources/views/admin/me/show.blade.php
Normal file
138
resources/views/admin/me/show.blade.php
Normal 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
|
||||
@ -1,20 +1,52 @@
|
||||
@if ($errors->any())
|
||||
<div class="a-alert a-alert--danger" role="alert">
|
||||
<div class="a-alert__title">확인해 주세요</div>
|
||||
<div class="a-alert__body">{{ $errors->first() }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@php
|
||||
$toasts = [];
|
||||
|
||||
@if (session('status'))
|
||||
<div class="a-alert a-alert--info" role="status">
|
||||
<div class="a-alert__title">안내</div>
|
||||
<div class="a-alert__body">{{ session('status') }}</div>
|
||||
</div>
|
||||
@endif
|
||||
// 컨트롤러에서 with('toast', [...])로 보낸 경우
|
||||
if (session()->has('toast')) {
|
||||
$t = session('toast');
|
||||
if (is_array($t)) $toasts[] = $t;
|
||||
}
|
||||
|
||||
@if (session('alert'))
|
||||
<div class="a-alert a-alert--warn" role="alert">
|
||||
<div class="a-alert__title">알림</div>
|
||||
<div class="a-alert__body">{{ session('alert') }}</div>
|
||||
// 라라벨 기본 status/error도 같이 지원
|
||||
if (session()->has('status')) {
|
||||
$toasts[] = ['type' => 'success', 'title' => '안내', 'message' => (string) session('status')];
|
||||
}
|
||||
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>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const wrap = document.getElementById('aToastWrap');
|
||||
if(!wrap) return;
|
||||
setTimeout(() => {
|
||||
wrap.querySelectorAll('.a-toast').forEach(el => el.remove());
|
||||
}, 3500);
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@ -81,14 +81,6 @@
|
||||
];
|
||||
@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">
|
||||
@foreach($menu as $group)
|
||||
<div class="a-nav__group">
|
||||
@ -96,12 +88,13 @@
|
||||
|
||||
@foreach($group['items'] as $it)
|
||||
@php
|
||||
$isActive = \Illuminate\Support\Facades\Route::has($it['route'])
|
||||
? request()->routeIs($it['route'])
|
||||
: false;
|
||||
$has = \Illuminate\Support\Facades\Route::has($it['route']);
|
||||
// index면 admin.admins.* 전체를 active로 잡아줌
|
||||
$base = preg_replace('/\.index$/', '', $it['route']);
|
||||
$isActive = $has ? request()->routeIs($base.'*') : false;
|
||||
@endphp
|
||||
|
||||
@if(\Illuminate\Support\Facades\Route::has($it['route']))
|
||||
@if($has)
|
||||
<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__label">{{ $it['label'] }}</span>
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
<div class="a-top__left">
|
||||
<div class="a-top__title">
|
||||
<div class="a-top__h">대시보드</div>
|
||||
<div class="a-top__sub a-muted">가입/로그인/문의/매출 흐름을 한눈에 확인</div>
|
||||
<div class="a-top__h">@yield('page_title', '콘솔')</div>
|
||||
<div class="a-top__sub a-muted">@yield('page_desc', '관리자 콘솔')</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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__avatar" aria-hidden="true">
|
||||
{{ mb_substr(auth('admin')->user()->name ?? 'A', 0, 1) }}
|
||||
</div>
|
||||
@php $u = auth('admin')->user(); @endphp
|
||||
<div class="a-top__userName">{{ ($u->nickname ?? '') !== '' ? $u->nickname : ($u->name ?? 'Admin') }}</div>
|
||||
<div class="a-top__userMeta a-muted">{{ $u->email ?? '' }}</div>
|
||||
<div class="a-top__userText">
|
||||
<div class="a-top__userName">{{ auth('admin')->user()->name ?? 'Admin' }}</div>
|
||||
<div class="a-top__userMeta a-muted">{{ auth('admin')->user()->email ?? '' }}</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.logout') }}">
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Auth\AdminAuthController;
|
||||
use App\Http\Controllers\Admin\AdminAdminsController;
|
||||
use App\Http\Controllers\Admin\MeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['web'])->group(function () {
|
||||
|
||||
// ✅ 로그인/OTP/비번초기화는 guest:admin 만 접근
|
||||
// 로그인/OTP/비번초기화는 guest:admin 만 접근
|
||||
Route::middleware('guest:admin')->group(function () {
|
||||
|
||||
Route::get('/login', [AdminAuthController::class, 'showLogin'])
|
||||
@ -30,15 +32,35 @@ Route::middleware(['web'])->group(function () {
|
||||
->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('/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'])
|
||||
->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')) {
|
||||
require __DIR__.'/dev_admin.php';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user