Move admin dashboard to admin.myhoneydue.com subdomain
- Remove Next.js basePath "/admin" — admin now serves at root - Update all internal links from /admin/xxx to /xxx - Change Go proxy to host-based routing: admin subdomain requests proxy to Next.js, /admin/* redirects to main web app - Update timeout middleware skipper for admin subdomain Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
basePath: "/admin",
|
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default function AppleSocialAuthPage() {
|
|||||||
<TableCell className="font-mono text-sm text-muted-foreground">{entry.id}</TableCell>
|
<TableCell className="font-mono text-sm text-muted-foreground">{entry.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${entry.user_id}`}
|
href={`/users/${entry.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{entry.username}
|
{entry.username}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export default function AuthTokensPage() {
|
|||||||
<TableCell className="font-mono text-sm text-muted-foreground">{token.user_id}</TableCell>
|
<TableCell className="font-mono text-sm text-muted-foreground">{token.user_id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${token.user_id}`}
|
href={`/users/${token.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{token.username}
|
{token.username}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export default function CompletionImagesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/tasks/${img.task_id}`}
|
href={`/tasks/${img.task_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{img.task_title || `Task #${img.task_id}`}
|
{img.task_title || `Task #${img.task_id}`}
|
||||||
@@ -234,7 +234,7 @@ export default function CompletionImagesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/completions/${img.completion_id}`}
|
href={`/completions/${img.completion_id}`}
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
#{img.completion_id}
|
#{img.completion_id}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function ConfirmationCodesPage() {
|
|||||||
<TableCell className="font-mono text-sm text-muted-foreground">{code.id}</TableCell>
|
<TableCell className="font-mono text-sm text-muted-foreground">{code.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${code.user_id}`}
|
href={`/users/${code.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{code.username}
|
{code.username}
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export default function DevicesPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{device.user_id ? (
|
{device.user_id ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${device.user_id}`}
|
href={`/users/${device.user_id}`}
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{device.username || `User #${device.user_id}`}
|
{device.username || `User #${device.user_id}`}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export default function DocumentImagesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/documents/${img.document_id}`}
|
href={`/documents/${img.document_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{img.document_title || `Document #${img.document_id}`}
|
{img.document_title || `Document #${img.document_id}`}
|
||||||
@@ -229,7 +229,7 @@ export default function DocumentImagesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/residences/${img.residence_id}`}
|
href={`/residences/${img.residence_id}`}
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{img.residence_name || `#${img.residence_id}`}
|
{img.residence_name || `#${img.residence_id}`}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function NotificationPrefsPage() {
|
|||||||
header: 'User',
|
header: 'User',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${row.original.user_id}`}
|
href={`/users/${row.original.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{row.original.username}
|
{row.original.username}
|
||||||
@@ -265,7 +265,7 @@ export default function NotificationPrefsPage() {
|
|||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/admin/users/${pref.user_id}`}>View User</Link>
|
<Link href={`/users/${pref.user_id}`}>View User</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const columns: ColumnDef<OnboardingEmail>[] = [
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/admin/users/${email.user_id}`}>View user</Link>
|
<Link href={`/users/${email.user_id}`}>View user</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function PasswordResetCodesPage() {
|
|||||||
<TableCell className="font-mono text-sm text-muted-foreground">{code.id}</TableCell>
|
<TableCell className="font-mono text-sm text-muted-foreground">{code.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${code.user_id}`}
|
href={`/users/${code.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{code.username}
|
{code.username}
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export default function ShareCodesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/residences/${code.residence_id}`}
|
href={`/residences/${code.residence_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{code.residence_name}
|
{code.residence_name}
|
||||||
@@ -240,7 +240,7 @@ export default function ShareCodesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${code.created_by_id}`}
|
href={`/users/${code.created_by_id}`}
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{code.created_by}
|
{code.created_by}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default function UserProfilesPage() {
|
|||||||
<TableCell className="font-mono text-sm text-muted-foreground">{profile.id}</TableCell>
|
<TableCell className="font-mono text-sm text-muted-foreground">{profile.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${profile.user_id}`}
|
href={`/users/${profile.user_id}`}
|
||||||
className="font-medium text-blue-600 hover:underline"
|
className="font-medium text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{profile.username}
|
{profile.username}
|
||||||
|
|||||||
@@ -49,40 +49,40 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ title: 'Dashboard', url: '/admin/', icon: Home },
|
{ title: 'Dashboard', url: '/', icon: Home },
|
||||||
{ title: 'Users', url: '/admin/users', icon: Users },
|
{ title: 'Users', url: '/users', icon: Users },
|
||||||
{ title: 'User Profiles', url: '/admin/user-profiles', icon: UserCircle },
|
{ title: 'User Profiles', url: '/user-profiles', icon: UserCircle },
|
||||||
{ title: 'Apple Sign In', url: '/admin/apple-social-auth', icon: Apple },
|
{ title: 'Apple Sign In', url: '/apple-social-auth', icon: Apple },
|
||||||
{ title: 'Auth Tokens', url: '/admin/auth-tokens', icon: Key },
|
{ title: 'Auth Tokens', url: '/auth-tokens', icon: Key },
|
||||||
{ title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail },
|
{ title: 'Confirmation Codes', url: '/confirmation-codes', icon: Mail },
|
||||||
{ title: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound },
|
{ title: 'Password Resets', url: '/password-reset-codes', icon: KeyRound },
|
||||||
{ title: 'Residences', url: '/admin/residences', icon: Building2 },
|
{ title: 'Residences', url: '/residences', icon: Building2 },
|
||||||
{ title: 'Share Codes', url: '/admin/share-codes', icon: Share2 },
|
{ title: 'Share Codes', url: '/share-codes', icon: Share2 },
|
||||||
{ title: 'Tasks', url: '/admin/tasks', icon: ClipboardList },
|
{ title: 'Tasks', url: '/tasks', icon: ClipboardList },
|
||||||
{ title: 'Completions', url: '/admin/completions', icon: CheckCircle },
|
{ title: 'Completions', url: '/completions', icon: CheckCircle },
|
||||||
{ title: 'Completion Images', url: '/admin/completion-images', icon: Image },
|
{ title: 'Completion Images', url: '/completion-images', icon: Image },
|
||||||
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
|
{ title: 'Contractors', url: '/contractors', icon: Wrench },
|
||||||
{ title: 'Documents', url: '/admin/documents', icon: FileText },
|
{ title: 'Documents', url: '/documents', icon: FileText },
|
||||||
{ title: 'Document Images', url: '/admin/document-images', icon: ImagePlus },
|
{ title: 'Document Images', url: '/document-images', icon: ImagePlus },
|
||||||
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
|
{ title: 'Notifications', url: '/notifications', icon: Bell },
|
||||||
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
|
{ title: 'Notification Prefs', url: '/notification-prefs', icon: BellRing },
|
||||||
{ title: 'Onboarding Emails', url: '/admin/onboarding-emails', icon: MailCheck },
|
{ title: 'Onboarding Emails', url: '/onboarding-emails', icon: MailCheck },
|
||||||
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
|
{ title: 'Devices', url: '/devices', icon: Smartphone },
|
||||||
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
|
{ title: 'Subscriptions', url: '/subscriptions', icon: CreditCard },
|
||||||
];
|
];
|
||||||
|
|
||||||
const limitationsItems = [
|
const limitationsItems = [
|
||||||
{ title: 'Tier Limits', url: '/admin/limitations', icon: Layers },
|
{ title: 'Tier Limits', url: '/limitations', icon: Layers },
|
||||||
{ title: 'Upgrade Triggers', url: '/admin/limitations/triggers', icon: Sparkles },
|
{ title: 'Upgrade Triggers', url: '/limitations/triggers', icon: Sparkles },
|
||||||
];
|
];
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ title: 'Monitoring', url: '/admin/monitoring', icon: Activity },
|
{ title: 'Monitoring', url: '/monitoring', icon: Activity },
|
||||||
{ title: 'Automation Reference', url: '/admin/automation-reference', icon: Cog },
|
{ title: 'Automation Reference', url: '/automation-reference', icon: Cog },
|
||||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
{ title: 'Lookup Tables', url: '/lookups', icon: BookOpen },
|
||||||
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
|
{ title: 'Task Templates', url: '/task-templates', icon: LayoutTemplate },
|
||||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
{ title: 'Admin Users', url: '/admin-users', icon: UserCog },
|
||||||
{ title: 'Settings', url: '/admin/settings', icon: Settings },
|
{ title: 'Settings', url: '/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
@@ -134,7 +134,7 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
isActive={pathname === item.url || (item.url !== '/admin/limitations' && pathname.startsWith(item.url))}
|
isActive={pathname === item.url || (item.url !== '/limitations' && pathname.startsWith(item.url))}
|
||||||
>
|
>
|
||||||
<a href={item.url}>
|
<a href={item.url}>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ api.interceptors.response.use(
|
|||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('admin_token');
|
localStorage.removeItem('admin_token');
|
||||||
window.location.href = '/admin/login/';
|
window.location.href = '/login/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -447,7 +448,10 @@ func SetupRoutes(router *echo.Echo, db *gorm.DB, cfg *config.Config, deps *Depen
|
|||||||
setupAdminProxy(router)
|
setupAdminProxy(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupAdminProxy configures reverse proxy to the Next.js admin panel
|
// setupAdminProxy configures reverse proxy to the Next.js admin panel.
|
||||||
|
// When ADMIN_HOST is set (e.g. admin.myhoneydue.com), requests to that
|
||||||
|
// subdomain are proxied to Next.js at the root path. Requests to /admin/*
|
||||||
|
// on the admin subdomain redirect to the main web app.
|
||||||
func setupAdminProxy(router *echo.Echo) {
|
func setupAdminProxy(router *echo.Echo) {
|
||||||
// Get admin panel URL from env, default to localhost:3001
|
// Get admin panel URL from env, default to localhost:3001
|
||||||
// Note: In production (Dokku), Next.js runs on internal port 3001
|
// Note: In production (Dokku), Next.js runs on internal port 3001
|
||||||
@@ -456,19 +460,6 @@ func setupAdminProxy(router *echo.Echo) {
|
|||||||
adminURL = "http://127.0.0.1:3001"
|
adminURL = "http://127.0.0.1:3001"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin subdomain (e.g. admin.myhoneydue.com) — redirect root to /admin/
|
|
||||||
adminHost := os.Getenv("ADMIN_HOST")
|
|
||||||
if adminHost != "" {
|
|
||||||
router.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
if c.Request().Host == adminHost && c.Request().URL.Path == "/" {
|
|
||||||
return c.Redirect(http.StatusMovedPermanently, "/admin/")
|
|
||||||
}
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := url.Parse(adminURL)
|
target, err := url.Parse(adminURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -476,18 +467,40 @@ func setupAdminProxy(router *echo.Echo) {
|
|||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
|
||||||
// Handle all /admin/* requests
|
// Admin subdomain: proxy all non-API requests to Next.js
|
||||||
router.Any("/admin/*", func(c echo.Context) error {
|
adminHost := os.Getenv("ADMIN_HOST")
|
||||||
proxy.ServeHTTP(c.Response(), c.Request())
|
webAppURL := os.Getenv("WEB_APP_URL")
|
||||||
return nil
|
if webAppURL == "" {
|
||||||
})
|
webAppURL = "https://myhoneydue.com"
|
||||||
|
}
|
||||||
|
|
||||||
// Also handle /admin without trailing path
|
if adminHost != "" {
|
||||||
router.Any("/admin", func(c echo.Context) error {
|
router.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return c.Redirect(http.StatusMovedPermanently, "/admin/")
|
return func(c echo.Context) error {
|
||||||
})
|
if c.Request().Host != adminHost {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
// Proxy Next.js static assets
|
path := c.Request().URL.Path
|
||||||
|
|
||||||
|
// Redirect /admin/* to the main web app
|
||||||
|
if strings.HasPrefix(path, "/admin") {
|
||||||
|
return c.Redirect(http.StatusMovedPermanently, webAppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let /api/* routes pass through to the Go API
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy everything else to Next.js admin
|
||||||
|
proxy.ServeHTTP(c.Response(), c.Request())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy Next.js static assets (served from /_next/ regardless of host)
|
||||||
router.Any("/_next/*", func(c echo.Context) error {
|
router.Any("/_next/*", func(c echo.Context) error {
|
||||||
proxy.ServeHTTP(c.Response(), c.Request())
|
proxy.ServeHTTP(c.Response(), c.Request())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -73,7 +74,9 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
|||||||
// timeout middleware wraps the response writer in *http.timeoutWriter
|
// timeout middleware wraps the response writer in *http.timeoutWriter
|
||||||
// which does not implement http.Flusher, causing a panic when
|
// which does not implement http.Flusher, causing a panic when
|
||||||
// httputil.ReverseProxy or WebSocket upgraders try to flush.
|
// httputil.ReverseProxy or WebSocket upgraders try to flush.
|
||||||
return strings.HasPrefix(path, "/admin") ||
|
// Also skip for admin subdomain (all requests proxied to Next.js).
|
||||||
|
adminHost := os.Getenv("ADMIN_HOST")
|
||||||
|
return (adminHost != "" && c.Request().Host == adminHost) ||
|
||||||
strings.HasPrefix(path, "/_next") ||
|
strings.HasPrefix(path, "/_next") ||
|
||||||
strings.HasSuffix(path, "/ws")
|
strings.HasSuffix(path, "/ws")
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user