Browse Source

Add CellularManagement.Web and ignore node_modules

ws
root 3 months ago
parent
commit
50802175b9
  1. 5
      .gitignore
  2. 1
      src/CellularManagement.Web/.env.development
  3. 1
      src/CellularManagement.Web/.env.example
  4. 1
      src/CellularManagement.Web/.env.production
  5. 31
      src/CellularManagement.Web/.gitignore
  6. BIN
      src/CellularManagement.Web/.yarn/install-state.gz
  7. 1
      src/CellularManagement.Web/.yarnrc.yml
  8. 16
      src/CellularManagement.Web/components.json
  9. 13
      src/CellularManagement.Web/index.html
  10. 6004
      src/CellularManagement.Web/package-lock.json
  11. 59
      src/CellularManagement.Web/package.json
  12. 6
      src/CellularManagement.Web/postcss.config.js
  13. 54
      src/CellularManagement.Web/src/App.tsx
  14. 28
      src/CellularManagement.Web/src/components/Header.tsx
  15. 19
      src/CellularManagement.Web/src/components/Layout.tsx
  16. 57
      src/CellularManagement.Web/src/components/Sidebar.tsx
  17. 43
      src/CellularManagement.Web/src/components/layout/Header.tsx
  18. 17
      src/CellularManagement.Web/src/components/layout/Layout.tsx
  19. 38
      src/CellularManagement.Web/src/components/layout/Sidebar.tsx
  20. 56
      src/CellularManagement.Web/src/components/ui/button.tsx
  21. 78
      src/CellularManagement.Web/src/components/ui/card.tsx
  22. 28
      src/CellularManagement.Web/src/components/ui/checkbox.tsx
  23. 50
      src/CellularManagement.Web/src/components/ui/dropdown-menu.tsx
  24. 24
      src/CellularManagement.Web/src/components/ui/input.tsx
  25. 20
      src/CellularManagement.Web/src/components/ui/label.tsx
  26. 127
      src/CellularManagement.Web/src/components/ui/toast.tsx
  27. 33
      src/CellularManagement.Web/src/components/ui/toaster.tsx
  28. 1
      src/CellularManagement.Web/src/components/ui/use-toast.ts
  29. 10
      src/CellularManagement.Web/src/env.d.ts
  30. 21
      src/CellularManagement.Web/src/hooks/use-theme.ts
  31. 191
      src/CellularManagement.Web/src/hooks/use-toast.ts
  32. 76
      src/CellularManagement.Web/src/index.css
  33. 73
      src/CellularManagement.Web/src/lib/api-client.ts
  34. 191
      src/CellularManagement.Web/src/lib/use-toast.ts
  35. 6
      src/CellularManagement.Web/src/lib/utils.ts
  36. 13
      src/CellularManagement.Web/src/main.tsx
  37. 48
      src/CellularManagement.Web/src/pages/Dashboard.tsx
  38. 120
      src/CellularManagement.Web/src/pages/ForgotPassword.tsx
  39. 148
      src/CellularManagement.Web/src/pages/Login.tsx
  40. 82
      src/CellularManagement.Web/src/pages/Login/LoginForm.tsx
  41. 103
      src/CellularManagement.Web/src/pages/LoginPage.tsx
  42. 147
      src/CellularManagement.Web/src/pages/Register.tsx
  43. 151
      src/CellularManagement.Web/src/pages/ResetPassword.tsx
  44. 102
      src/CellularManagement.Web/src/pages/UserManagement.tsx
  45. 94
      src/CellularManagement.Web/src/services/api.ts
  46. 77
      src/CellularManagement.Web/src/services/auth.service.ts
  47. 17
      src/CellularManagement.Web/src/services/auth.ts
  48. 106
      src/CellularManagement.Web/src/services/authService.ts
  49. 74
      src/CellularManagement.Web/tailwind.config.js
  50. 25
      src/CellularManagement.Web/tsconfig.json
  51. 10
      src/CellularManagement.Web/tsconfig.node.json
  52. 23
      src/CellularManagement.Web/vite.config.ts
  53. 4921
      src/CellularManagement.Web/yarn.lock

5
.gitignore

@ -38,4 +38,7 @@ artifacts/
*.suo
*.user
*.userosscache
*.sln.docstates
*.sln.docstates
# Node modules
node_modules/

1
src/CellularManagement.Web/.env.development

@ -0,0 +1 @@
VITE_API_BASE_URL=https://localhost:5001/api/v1

1
src/CellularManagement.Web/.env.example

@ -0,0 +1 @@
VITE_API_BASE_URL=https://your-api-url/api/v1

1
src/CellularManagement.Web/.env.production

@ -0,0 +1 @@
VITE_API_BASE_URL=https://localhost:5001/api/v1

31
src/CellularManagement.Web/.gitignore

@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

BIN
src/CellularManagement.Web/.yarn/install-state.gz

Binary file not shown.

1
src/CellularManagement.Web/.yarnrc.yml

@ -0,0 +1 @@
nodeLinker: node-modules

16
src/CellularManagement.Web/components.json

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

13
src/CellularManagement.Web/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cellular Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6004
src/CellularManagement.Web/package-lock.json

File diff suppressed because it is too large

59
src/CellularManagement.Web/package.json

@ -0,0 +1,59 @@
{
"name": "CellularManagementAdmin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prepare": "husky install"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.2.10",
"@types/axios": "^0.14.4",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"crypto-js": "^4.2.0",
"jsencrypt": "^3.3.2",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.1",
"recoil": "^0.7.7",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.17",
"eslint": "^9.20.1",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^6.1.0"
},
"engines": {
"yarn": ">=1.22.0"
},
"packageManager": "yarn@4.1.0"
}

6
src/CellularManagement.Web/postcss.config.js

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

54
src/CellularManagement.Web/src/App.tsx

@ -0,0 +1,54 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/contexts/AuthContext'
import Layout from '@/components/Layout'
import Dashboard from '@/pages/Dashboard'
import Login from '@/pages/Login'
import Register from '@/pages/Register'
import ForgotPassword from '@/pages/ForgotPassword'
import ResetPassword from '@/pages/ResetPassword'
import UserManagement from '@/pages/UserManagement'
import { ToastProvider } from '@/components/ui/toast'
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div>Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
return (
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="users" element={<UserManagement />} />
</Route>
</Routes>
</ToastProvider>
</AuthProvider>
)
}
export default App

28
src/CellularManagement.Web/src/components/Header.tsx

@ -0,0 +1,28 @@
import { useTheme } from '@/hooks/use-theme';
import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
export default function Header() {
const { theme, setTheme } = useTheme();
return (
<header className="h-16 border-b flex items-center justify-between px-4">
<div className="flex items-center">
<h1 className="text-xl font-semibold">Cellular Management</h1>
</div>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</div>
</header>
);
}

19
src/CellularManagement.Web/src/components/Layout.tsx

@ -0,0 +1,19 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import Header from './Header';
export default function Layout() {
return (
<div className="h-full">
<div className="hidden md:flex h-full w-72 flex-col fixed inset-y-0">
<Sidebar />
</div>
<main className="md:pl-72 h-full">
<Header />
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
<Outlet />
</div>
</main>
</div>
);
}

57
src/CellularManagement.Web/src/components/Sidebar.tsx

@ -0,0 +1,57 @@
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { LayoutDashboard, Users, Settings } from 'lucide-react';
const routes = [
{
label: 'Dashboard',
icon: LayoutDashboard,
href: '/dashboard',
color: 'text-sky-500'
},
{
label: 'Users',
icon: Users,
href: '/users',
color: 'text-violet-500'
},
{
label: 'Settings',
icon: Settings,
href: '/settings',
color: 'text-gray-500'
}
];
export default function Sidebar() {
const location = useLocation();
return (
<div className="space-y-4 py-4 flex flex-col h-full bg-[#111827] text-white">
<div className="px-3 py-2 flex-1">
<Link to="/dashboard" className="flex items-center pl-3 mb-14">
<h1 className="text-2xl font-bold">
CellularManagement
</h1>
</Link>
<div className="space-y-1">
{routes.map((route) => (
<Link
key={route.href}
to={route.href}
className={cn(
'text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:text-white hover:bg-white/10 rounded-lg transition',
location.pathname === route.href ? 'text-white bg-white/10' : 'text-zinc-400'
)}
>
<div className="flex items-center flex-1">
<route.icon className={cn('h-5 w-5 mr-3', route.color)} />
{route.label}
</div>
</Link>
))}
</div>
</div>
</div>
);
}

43
src/CellularManagement.Web/src/components/layout/Header.tsx

@ -0,0 +1,43 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/hooks/use-theme'
import { Moon, Sun, User } from 'lucide-react'
export function Header() {
const { theme, setTheme } = useTheme()
return (
<header className="border-b">
<div className="flex h-16 items-center px-4">
<div className="ml-auto flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<User className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
)
}

17
src/CellularManagement.Web/src/components/layout/Layout.tsx

@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
export default function Layout() {
return (
<div className="flex h-screen bg-background">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4">
<Outlet />
</main>
</div>
</div>
)
}

38
src/CellularManagement.Web/src/components/layout/Sidebar.tsx

@ -0,0 +1,38 @@
import { NavLink } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { LayoutDashboard, Settings, Users } from 'lucide-react'
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Users', href: '/users', icon: Users },
{ name: 'Settings', href: '/settings', icon: Settings },
]
export function Sidebar() {
return (
<div className="flex h-full w-64 flex-col border-r">
<div className="flex h-16 items-center border-b px-4">
<h1 className="text-xl font-semibold">Cellular Management</h1>
</div>
<nav className="flex-1 space-y-1 p-2">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
cn(
'flex items-center rounded-md px-3 py-2 text-sm font-medium',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</NavLink>
))}
</nav>
</div>
)
}

56
src/CellularManagement.Web/src/components/ui/button.tsx

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

78
src/CellularManagement.Web/src/components/ui/card.tsx

@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

28
src/CellularManagement.Web/src/components/ui/checkbox.tsx

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "../../lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

50
src/CellularManagement.Web/src/components/ui/dropdown-menu.tsx

@ -0,0 +1,50 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
}

24
src/CellularManagement.Web/src/components/ui/input.tsx

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

20
src/CellularManagement.Web/src/components/ui/label.tsx

@ -0,0 +1,20 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

127
src/CellularManagement.Web/src/components/ui/toast.tsx

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

33
src/CellularManagement.Web/src/components/ui/toaster.tsx

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

1
src/CellularManagement.Web/src/components/ui/use-toast.ts

@ -0,0 +1 @@

10
src/CellularManagement.Web/src/env.d.ts

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
// Add other environment variables here
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

21
src/CellularManagement.Web/src/hooks/use-theme.ts

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
type Theme = 'light' | 'dark'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'light'
}
return 'light'
})
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}, [theme])
return { theme, setTheme }
}

191
src/CellularManagement.Web/src/hooks/use-toast.ts

@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

76
src/CellularManagement.Web/src/index.css

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

73
src/CellularManagement.Web/src/lib/api-client.ts

@ -0,0 +1,73 @@
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { toast } from '@/hooks/use-toast'
interface ImportMetaEnv {
VITE_API_URL?: string
}
interface ImportMeta {
env: ImportMetaEnv
}
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
apiClient.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
}
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
if (error.response) {
switch (error.response.status) {
case 401:
// Handle unauthorized access
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
toast({
title: 'Access Denied',
description: 'You do not have permission to perform this action.',
variant: 'destructive',
})
break
case 500:
toast({
title: 'Server Error',
description: 'Something went wrong on our end. Please try again later.',
variant: 'destructive',
})
break
default:
toast({
title: 'Error',
description: error.response.data?.message || 'An error occurred',
variant: 'destructive',
})
}
}
return Promise.reject(error)
}
)
export default apiClient

191
src/CellularManagement.Web/src/lib/use-toast.ts

@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "../components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

6
src/CellularManagement.Web/src/lib/utils.ts

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
src/CellularManagement.Web/src/main.tsx

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

48
src/CellularManagement.Web/src/pages/Dashboard.tsx

@ -0,0 +1,48 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, Activity, Signal } from 'lucide-react'
export default function Dashboard() {
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">1,234</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Cells</CardTitle>
<Signal className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">45</div>
<p className="text-xs text-muted-foreground">
+2 from last week
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Network Activity</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2.3 TB</div>
<p className="text-xs text-muted-foreground">
+12% from last month
</p>
</CardContent>
</Card>
</div>
</div>
)
}

120
src/CellularManagement.Web/src/pages/ForgotPassword.tsx

@ -0,0 +1,120 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
import { authService } from '@/services/auth.service'
const ForgotPassword: React.FC = () => {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const { toast } = useToast()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await authService.requestPasswordReset(email)
setIsSubmitted(true)
toast({
title: 'Success',
description: 'If an account exists with this email, you will receive password reset instructions.',
})
} catch (error) {
toast({
title: 'Error',
description: 'An error occurred while processing your request.',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
if (isSubmitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Check your email
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
We've sent password reset instructions to your email address.
</p>
<div className="mt-4 text-center">
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Return to login
</Link>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Enter your email address and we'll send you instructions to reset your password.
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<Label htmlFor="email">Email address</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
</div>
<div>
<Button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Send reset instructions'
)}
</Button>
</div>
<div className="text-center">
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Back to login
</Link>
</div>
</form>
</div>
</div>
)
}
export default ForgotPassword

148
src/CellularManagement.Web/src/pages/Login.tsx

@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
import { authService } from '@/services/authService'
const Login: React.FC = () => {
const [usernameOrEmail, setUsernameOrEmail] = useState('')
const [password, setPassword] = useState('')
const [rememberMe, setRememberMe] = useState(authService.isRemembered())
const [isLoading, setIsLoading] = useState(false)
const { login } = useAuth()
const { toast } = useToast()
const navigate = useNavigate()
useEffect(() => {
if (authService.isRemembered()) {
const lastUsername = localStorage.getItem('last_username');
if (lastUsername) {
setUsernameOrEmail(lastUsername);
}
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await login({ usernameOrEmail, password, rememberMe })
if (rememberMe) {
localStorage.setItem('last_username', usernameOrEmail);
} else {
localStorage.removeItem('last_username');
}
toast({
title: 'Login successful',
description: 'Welcome back!',
})
} catch (error) {
toast({
title: 'Login failed',
description: 'Invalid username/email or password',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
to="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<Label htmlFor="usernameOrEmail">Username or Email</Label>
<Input
id="usernameOrEmail"
name="usernameOrEmail"
type="text"
autoComplete="username"
required
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username or Email"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<Label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-white">
Remember me
</Label>
</div>
<div className="text-sm">
<Link
to="/forgot-password"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<Button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Sign in'
)}
</Button>
</div>
</form>
</div>
</div>
)
}
export default Login

82
src/CellularManagement.Web/src/pages/Login/LoginForm.tsx

@ -0,0 +1,82 @@
import { 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 { useToast } from "@/lib/use-toast"
import { login } from "@/services/auth"
import { useAuth } from "@/hooks/useAuth"
export function LoginForm() {
const navigate = useNavigate()
const { toast } = useToast()
const { setToken } = useAuth()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
username: "",
password: "",
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const response = await login(formData)
setToken(response.data.token)
toast({
title: "登录成功",
description: "欢迎回来!",
})
navigate("/dashboard")
} catch (error) {
toast({
title: "登录失败",
description: "用户名或密码错误",
variant: "destructive",
})
} finally {
setLoading(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
name="username"
type="text"
placeholder="请输入用户名"
value={formData.username}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
name="password"
type="password"
placeholder="请输入密码"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
)
}

103
src/CellularManagement.Web/src/pages/LoginPage.tsx

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Checkbox } from '../components/ui/checkbox';
import { useToast } from '../components/ui/use-toast';
export const LoginPage: React.FC = () => {
const [usernameOrEmail, setUsernameOrEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await login(usernameOrEmail, password, rememberMe);
toast({
title: 'Login successful',
description: 'Welcome back!',
});
navigate('/dashboard');
} catch (error) {
toast({
title: 'Login failed',
description: 'Invalid username/email or password',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Sign in to your account
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<Label htmlFor="usernameOrEmail">Username or Email</Label>
<Input
id="usernameOrEmail"
name="usernameOrEmail"
type="text"
required
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Checkbox
id="rememberMe"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
/>
<Label htmlFor="rememberMe" className="ml-2">
Remember me
</Label>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</div>
</form>
</div>
</div>
);
};

147
src/CellularManagement.Web/src/pages/Register.tsx

@ -0,0 +1,147 @@
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
const Register: React.FC = () => {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const { register } = useAuth()
const { toast } = useToast()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
if (password !== confirmPassword) {
toast({
title: 'Error',
description: 'Passwords do not match',
variant: 'destructive',
})
setIsLoading(false)
return
}
try {
await register(name, email, password)
toast({
title: 'Registration successful',
description: 'Welcome to our platform!',
})
} catch (error) {
toast({
title: 'Registration failed',
description: 'An error occurred during registration',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
sign in to your account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<Label htmlFor="name">Full name</Label>
<Input
id="name"
name="name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Full name"
/>
</div>
<div>
<Label htmlFor="email">Email address</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
<div>
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
name="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Confirm password"
/>
</div>
</div>
<div>
<Button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Create account'
)}
</Button>
</div>
</form>
</div>
</div>
)
}
export default Register

151
src/CellularManagement.Web/src/pages/ResetPassword.tsx

@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
import { authService } from '@/services/auth.service'
const ResetPassword: React.FC = () => {
const [searchParams] = useSearchParams()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isValidToken, setIsValidToken] = useState(true)
const { toast } = useToast()
const navigate = useNavigate()
const token = searchParams.get('token')
useEffect(() => {
if (!token) {
setIsValidToken(false)
toast({
title: 'Invalid token',
description: 'The password reset link is invalid or has expired.',
variant: 'destructive',
})
}
}, [token, toast])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
if (password !== confirmPassword) {
toast({
title: 'Error',
description: 'Passwords do not match',
variant: 'destructive',
})
setIsLoading(false)
return
}
try {
await authService.resetPassword(token!, password)
toast({
title: 'Success',
description: 'Your password has been reset successfully.',
})
navigate('/login')
} catch (error) {
toast({
title: 'Error',
description: 'An error occurred while resetting your password.',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
if (!isValidToken) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Invalid Reset Link
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
The password reset link is invalid or has expired. Please request a new one.
</p>
<div className="mt-4 text-center">
<a
href="/forgot-password"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Request new reset link
</a>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Please enter your new password below.
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<Label htmlFor="password">New password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="New password"
/>
</div>
<div>
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
name="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Confirm new password"
/>
</div>
</div>
<div>
<Button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Reset password'
)}
</Button>
</div>
</form>
</div>
</div>
)
}
export default ResetPassword

102
src/CellularManagement.Web/src/pages/UserManagement.tsx

@ -0,0 +1,102 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { MoreVertical, Search, Plus } from 'lucide-react';
interface User {
id: string;
name: string;
email: string;
role: string;
status: 'active' | 'inactive';
}
export default function UserManagement() {
const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState<User[]>([
{
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'Admin',
status: 'active'
},
{
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'User',
status: 'active'
}
]);
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">User Management</h1>
<Button>
<Plus className="w-4 h-4 mr-2" />
Add User
</Button>
</div>
<Card className="p-4 mb-6">
<div className="flex items-center space-x-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
</Card>
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card key={user.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
{user.name.charAt(0)}
</div>
<div>
<h3 className="font-medium">{user.name}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<span className={`px-2 py-1 rounded-full text-xs ${
user.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.status}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
))}
</div>
</div>
);
}

94
src/CellularManagement.Web/src/services/api.ts

@ -0,0 +1,94 @@
import axios from 'axios';
import { authService } from './authService';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
if (!API_BASE_URL) {
throw new Error('VITE_API_BASE_URL is not defined in environment variables');
}
// 简单的 toast 函数
const showToast = (title: string, description: string, variant: 'default' | 'destructive' = 'default') => {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg ${
variant === 'destructive' ? 'bg-red-100 text-red-900' : 'bg-green-100 text-green-900'
}`;
toast.innerHTML = `
<h3 class="font-medium">${title}</h3>
<p class="text-sm">${description}</p>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
};
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = authService.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 如果是 401 错误且不是刷新 token 的请求,尝试刷新 token
if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/Auth/refresh') {
originalRequest._retry = true;
try {
await authService.refreshToken();
const newToken = authService.getToken();
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
}
} catch (refreshError) {
// 刷新 token 失败,清除所有认证信息并重定向到登录页
await authService.logout();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// 处理其他错误
if (error.response) {
switch (error.response.status) {
case 403:
showToast('权限不足', '您没有权限执行此操作', 'destructive');
break;
case 404:
showToast('资源未找到', '请求的资源不存在', 'destructive');
break;
case 500:
showToast('服务器错误', '服务器内部错误,请稍后重试', 'destructive');
break;
default:
showToast('请求错误', error.response.data?.message || '发生未知错误', 'destructive');
}
} else if (error.request) {
showToast('网络错误', '请检查网络连接', 'destructive');
} else {
showToast('请求错误', error.message || '发生未知错误', 'destructive');
}
return Promise.reject(error);
}
);

77
src/CellularManagement.Web/src/services/auth.service.ts

@ -0,0 +1,77 @@
import apiClient from '@/lib/api-client'
export interface LoginCredentials {
email: string
password: string
}
export interface RegisterCredentials {
email: string
password: string
name: string
}
export interface AuthResponse {
token: string
user: {
id: string
name: string
email: string
}
}
export interface ResetPasswordResponse {
message: string
success: boolean
}
class AuthService {
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', credentials)
return response.data
}
async register(credentials: RegisterCredentials): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/register', credentials)
return response.data
}
async logout(): Promise<void> {
localStorage.removeItem('token')
}
async requestPasswordReset(email: string): Promise<ResetPasswordResponse> {
const response = await apiClient.post<ResetPasswordResponse>('/auth/forgot-password', { email })
return response.data
}
async resetPassword(token: string, newPassword: string): Promise<ResetPasswordResponse> {
const response = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', {
token,
newPassword,
})
return response.data
}
getCurrentUser(): { id: string; name: string; email: string } | null {
const token = localStorage.getItem('token')
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return {
id: payload.sub,
name: payload.name,
email: payload.email,
}
} catch (error) {
return null
}
}
isAuthenticated(): boolean {
return !!localStorage.getItem('token')
}
}
export const authService = new AuthService()

17
src/CellularManagement.Web/src/services/auth.ts

@ -0,0 +1,17 @@
import axios from "axios"
const API_URL = import.meta.env.VITE_API_URL
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
}
export const login = async (data: LoginRequest): Promise<{ data: LoginResponse }> => {
const response = await axios.post(`${API_URL}/auth/login`, data)
return response
}

106
src/CellularManagement.Web/src/services/authService.ts

@ -0,0 +1,106 @@
import axios from 'axios';
import { api } from './api';
export interface LoginCredentials {
usernameOrEmail: string;
password: string;
rememberMe?: boolean;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
}
const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
const REMEMBER_ME_KEY = 'remember_me';
const TOKEN_EXPIRY_KEY = 'token_expiry';
export const authService = {
getToken: (): string | null => {
return localStorage.getItem('accessToken');
},
setToken: (token: string): void => {
localStorage.setItem('accessToken', token);
},
getRefreshToken: (): string | null => {
return localStorage.getItem('refreshToken');
},
setRefreshToken: (token: string): void => {
localStorage.setItem('refreshToken', token);
},
isTokenExpired: (): boolean => {
const token = authService.getToken();
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch (error) {
return true;
}
},
login: async (usernameOrEmail: string, password: string, rememberMe: boolean): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>('/Auth/login', {
usernameOrEmail,
password,
rememberMe
});
const { accessToken, refreshToken } = response.data;
authService.setToken(accessToken);
authService.setRefreshToken(refreshToken);
return response.data;
},
logout: async (): Promise<void> => {
try {
const refreshToken = authService.getRefreshToken();
if (refreshToken) {
await api.post('/Auth/revoke-token', { token: refreshToken });
}
} catch (error) {
console.error('Failed to revoke token:', error);
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
},
refreshToken: async (): Promise<string> => {
const refreshToken = authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post<LoginResponse>('/Auth/refresh-token', {
token: refreshToken
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
authService.setToken(accessToken);
authService.setRefreshToken(newRefreshToken);
return accessToken;
},
isRemembered: (): boolean => {
return localStorage.getItem(REMEMBER_ME_KEY) === 'true';
},
getCurrentUser: async (): Promise<LoginResponse['user'] | null> => {
try {
const response = await api.get<LoginResponse['user']>('/Auth/me');
return response.data;
} catch (error) {
return null;
}
}
};

74
src/CellularManagement.Web/tailwind.config.js

@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

25
src/CellularManagement.Web/tsconfig.json

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
src/CellularManagement.Web/tsconfig.node.json

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

23
src/CellularManagement.Web/vite.config.ts

@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/ws': {
target: 'ws://localhost:5001',
ws: true,
},
},
},
})

4921
src/CellularManagement.Web/yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save