From e161163362d660cbec550c23a3f76523948d123d Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 4 Nov 2025 21:28:00 +0700 Subject: [PATCH] feat: Implement standalone admin at /admin with custom login page and auth system --- admin-spa/src/App.tsx | 59 +++++++++- admin-spa/src/components/ui/alert.tsx | 59 ++++++++++ admin-spa/src/routes/Login.tsx | 153 ++++++++++++++++++++++++++ admin/index.php | 84 ++++++++++++++ includes/Api/AuthController.php | 114 +++++++++++++++++++ includes/Api/Routes.php | 21 ++++ 6 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 admin-spa/src/components/ui/alert.tsx create mode 100644 admin-spa/src/routes/Login.tsx create mode 100644 admin/index.php create mode 100644 includes/Api/AuthController.php diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 59e3257..e3caf95 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { HashRouter, Routes, Route, NavLink, useLocation, useParams } from 'react-router-dom'; +import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate } from 'react-router-dom'; import Dashboard from '@/routes/Dashboard'; import DashboardRevenue from '@/routes/Dashboard/Revenue'; import DashboardOrders from '@/routes/Dashboard/Orders'; @@ -19,6 +19,7 @@ import ProductAttributes from '@/routes/Products/Attributes'; import CouponsIndex from '@/routes/Coupons'; import CouponNew from '@/routes/Coupons/New'; import CustomersIndex from '@/routes/Customers'; +import { Login } from '@/routes/Login'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react'; import { Toaster } from 'sonner'; @@ -394,13 +395,63 @@ function Shell() { ); } +function AuthWrapper() { + const [isAuthenticated, setIsAuthenticated] = useState( + window.WNW_CONFIG?.isAuthenticated ?? true + ); + const [isChecking, setIsChecking] = useState(window.WNW_CONFIG?.standaloneMode ?? false); + const location = useLocation(); + + useEffect(() => { + if (window.WNW_CONFIG?.standaloneMode) { + fetch(window.WNW_CONFIG.restUrl + '/auth/check', { + credentials: 'include', + }) + .then(res => res.json()) + .then(data => { + setIsAuthenticated(data.authenticated); + if (data.authenticated && data.user) { + window.WNW_CONFIG.currentUser = data.user; + } + }) + .catch(() => setIsAuthenticated(false)) + .finally(() => setIsChecking(false)); + } + }, []); + + if (isChecking) { + return ( +
+ +
+ ); + } + + if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') { + return ; + } + + if (location.pathname === '/login' && isAuthenticated) { + return ; + } + + return ( + + + + ); +} + export default function App() { return ( - - - + + {window.WNW_CONFIG?.standaloneMode && ( + } /> + )} + } /> + svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/admin-spa/src/routes/Login.tsx b/admin-spa/src/routes/Login.tsx new file mode 100644 index 0000000..101afeb --- /dev/null +++ b/admin-spa/src/routes/Login.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, ArrowLeft } from 'lucide-react'; +import { __ } from '@/lib/i18n'; + +export function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const response = await fetch(window.WNW_CONFIG.restUrl + '/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // Update global config + window.WNW_CONFIG.isAuthenticated = true; + window.WNW_CONFIG.currentUser = data.user; + window.WNW_CONFIG.nonce = data.nonce; + + // Redirect to dashboard + navigate('/dashboard'); + + // Reload to ensure all auth state is fresh + window.location.reload(); + } else { + setError(data.message || __('Invalid username or password')); + } + } catch (err) { + console.error('Login error:', err); + setError(__('Login failed. Please try again.')); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ {/* Logo */} +
+

+ WooNooW +

+

+ {__('Sign in to your admin dashboard')} +

+
+ + {/* Error Alert */} + {error && ( + + {error} + + )} + + {/* Login Form */} +
+
+ + setUsername(e.target.value)} + placeholder={__('Enter your username')} + required + disabled={isLoading} + className="mt-1" + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder={__('Enter your password')} + required + disabled={isLoading} + className="mt-1" + autoComplete="current-password" + /> +
+ + +
+ + {/* Footer Links */} + +
+ + {/* Site Info */} +
+ {window.WNW_CONFIG.siteName} +
+
+
+ ); +} diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..8069856 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,84 @@ + $user->ID, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'avatar' => get_avatar_url( $user->ID ), + ]; +} + +// Get asset URLs +$plugin_url = plugins_url( '', dirname( __FILE__ ) ); +$asset_url = $plugin_url . '/admin-spa/dist'; +$css_url = $asset_url . '/app.css'; +$js_url = $asset_url . '/app.js'; + +// Add cache busting +$version = defined( 'WP_DEBUG' ) && WP_DEBUG ? time() : '1.0.0'; +$css_url .= '?ver=' . $version; +$js_url .= '?ver=' . $version; +?> + + + + + + + WooNooW Admin + + + + + +
+ + + + + + + + diff --git a/includes/Api/AuthController.php b/includes/Api/AuthController.php new file mode 100644 index 0000000..69210c9 --- /dev/null +++ b/includes/Api/AuthController.php @@ -0,0 +1,114 @@ +get_param( 'username' ) ); + $password = $request->get_param( 'password' ); + + if ( empty( $username ) || empty( $password ) ) { + return new WP_REST_Response( [ + 'success' => false, + 'message' => __( 'Username and password are required', 'woonoow' ), + ], 400 ); + } + + // Authenticate user + $user = wp_authenticate( $username, $password ); + + if ( is_wp_error( $user ) ) { + return new WP_REST_Response( [ + 'success' => false, + 'message' => __( 'Invalid username or password', 'woonoow' ), + ], 401 ); + } + + // Check if user has WooCommerce permissions + if ( ! user_can( $user, 'manage_woocommerce' ) ) { + return new WP_REST_Response( [ + 'success' => false, + 'message' => __( 'You do not have permission to access this area', 'woonoow' ), + ], 403 ); + } + + // Set auth cookie + wp_set_auth_cookie( $user->ID, true ); + + // Return user data and new nonce + return new WP_REST_Response( [ + 'success' => true, + 'user' => [ + 'id' => $user->ID, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'avatar' => get_avatar_url( $user->ID ), + ], + 'nonce' => wp_create_nonce( 'wp_rest' ), + ], 200 ); + } + + /** + * Logout endpoint + * + * @return WP_REST_Response Response object + */ + public static function logout(): WP_REST_Response { + wp_logout(); + + return new WP_REST_Response( [ + 'success' => true, + 'message' => __( 'Logged out successfully', 'woonoow' ), + ], 200 ); + } + + /** + * Check auth status + * + * @return WP_REST_Response Response object + */ + public static function check(): WP_REST_Response { + if ( ! is_user_logged_in() ) { + return new WP_REST_Response( [ + 'authenticated' => false, + ], 200 ); + } + + $user = wp_get_current_user(); + + // Check WooCommerce permission + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return new WP_REST_Response( [ + 'authenticated' => false, + 'message' => __( 'Insufficient permissions', 'woonoow' ), + ], 200 ); + } + + return new WP_REST_Response( [ + 'authenticated' => true, + 'user' => [ + 'id' => $user->ID, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'avatar' => get_avatar_url( $user->ID ), + ], + ], 200 ); + } +} diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index 5e98b52..023cf05 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -6,6 +6,7 @@ use WP_REST_Response; use WooNooW\Api\CheckoutController; use WooNooW\Api\OrdersController; use WooNooW\Api\AnalyticsController; +use WooNooW\Api\AuthController; class Routes { public static function init() { @@ -14,6 +15,26 @@ class Routes { add_action('rest_api_init', function () { $namespace = 'woonoow/v1'; + + // Auth endpoints (public - no permission check) + register_rest_route( $namespace, '/auth/login', [ + 'methods' => 'POST', + 'callback' => [ AuthController::class, 'login' ], + 'permission_callback' => '__return_true', + ] ); + + register_rest_route( $namespace, '/auth/logout', [ + 'methods' => 'POST', + 'callback' => [ AuthController::class, 'logout' ], + 'permission_callback' => '__return_true', + ] ); + + register_rest_route( $namespace, '/auth/check', [ + 'methods' => 'GET', + 'callback' => [ AuthController::class, 'check' ], + 'permission_callback' => '__return_true', + ] ); + // Defer to controllers to register their endpoints CheckoutController::register(); OrdersController::register();