Major updates, authentication, multi-users
This commit is contained in:
@@ -4,19 +4,28 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
|
|||||||
|
|
||||||
## Current scope
|
## Current scope
|
||||||
|
|
||||||
|
- Passwordless authentication only
|
||||||
|
- Magic-link email sign-in
|
||||||
|
- OAuth-ready login flow for Google, Microsoft, and Apple
|
||||||
|
- Multi-workspace model with `standard` household and `rescue` modes
|
||||||
|
- Shared workspace member management for both households and rescues
|
||||||
|
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, and `household_plus`
|
||||||
- Bird profiles with name, tag ID, and species
|
- Bird profiles with name, tag ID, and species
|
||||||
|
- Bird DOB and gotcha day fields
|
||||||
- Daily weight recordings
|
- Daily weight recordings
|
||||||
- 30-day weight graph
|
- 30-day weight graph
|
||||||
|
- Vet visit history with notes
|
||||||
- Postgres-backed storage
|
- Postgres-backed storage
|
||||||
- React frontend and Express backend
|
- React frontend and Express backend
|
||||||
- Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation
|
- Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation
|
||||||
|
|
||||||
## Planned next steps
|
## Planned next steps
|
||||||
|
|
||||||
- Vet visit history
|
|
||||||
- Medication and care reminders
|
- Medication and care reminders
|
||||||
- Accounts, authorization, and role-based rescue access
|
- Invitation acceptance and onboarding polish for workspace members
|
||||||
- Billing and plan management for paid organizations with a free rescue tier
|
- Stripe or equivalent billing integration for paid household tiers
|
||||||
|
- Scheduled reminder delivery for birthdays, gotcha days, and care events
|
||||||
|
- Audit logging for workspace access changes and bird transfers
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
@@ -30,6 +39,43 @@ docker compose up --build
|
|||||||
3. Open `http://localhost:3000`.
|
3. Open `http://localhost:3000`.
|
||||||
4. The API health check is available at `http://localhost:5000/api/health`.
|
4. The API health check is available at `http://localhost:5000/api/health`.
|
||||||
|
|
||||||
|
## Auth and workspace notes
|
||||||
|
|
||||||
|
- One user can belong to multiple workspaces.
|
||||||
|
- A rescue member can also keep their own household flock in a separate workspace.
|
||||||
|
- Billing should attach to the household workspace, not the user account.
|
||||||
|
- Rescue workspaces stay on the free plan.
|
||||||
|
- Shared access is controlled by workspace roles like `owner`, `manager`, `staff`, and `viewer`.
|
||||||
|
- FlockPal no longer stores local passwords.
|
||||||
|
- Authentication now happens through magic links or external identity providers.
|
||||||
|
|
||||||
|
## OAuth environment
|
||||||
|
|
||||||
|
Set these in Docker or your `.env` file if you want provider login enabled:
|
||||||
|
|
||||||
|
- `FRONTEND_URL`
|
||||||
|
- `BACKEND_URL`
|
||||||
|
- `GOOGLE_CLIENT_ID`
|
||||||
|
- `GOOGLE_CLIENT_SECRET`
|
||||||
|
- `MICROSOFT_CLIENT_ID`
|
||||||
|
- `MICROSOFT_CLIENT_SECRET`
|
||||||
|
- `APPLE_CLIENT_ID`
|
||||||
|
- `APPLE_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## Magic-link email environment
|
||||||
|
|
||||||
|
Set these if you want magic links delivered by email instead of logged as a preview URL during local development:
|
||||||
|
|
||||||
|
- `SMTP_HOST`
|
||||||
|
- `SMTP_PORT`
|
||||||
|
- `SMTP_SECURE`
|
||||||
|
- `SMTP_USER`
|
||||||
|
- `SMTP_PASS`
|
||||||
|
- `SMTP_FROM_EMAIL`
|
||||||
|
- `SMTP_FROM_NAME`
|
||||||
|
|
||||||
## Notes for monetization and security
|
## Notes for monetization and security
|
||||||
|
|
||||||
This starter keeps the data model and deployment simple, but it is intentionally shaped so we can add authentication, organization scoping, audit trails, reminders, and Stripe-style billing later without redesigning the whole app.
|
This starter now includes the account and workspace foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch.
|
||||||
|
|
||||||
|
For account design, `standard` vs `rescue` is best treated as a workspace type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than workspace roles such as `owner`, `manager`, `staff`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes.
|
||||||
|
|||||||
Generated
+20
-2
@@ -8,12 +8,14 @@
|
|||||||
"name": "flockpal-backend",
|
"name": "flockpal-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-rate-limit": "7.5.0",
|
"express-rate-limit": "7.5.0",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -513,12 +515,20 @@
|
|||||||
"version": "22.10.2",
|
"version": "22.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||||
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.11.10",
|
"version": "8.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz",
|
||||||
@@ -1239,6 +1249,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||||
|
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -1808,7 +1827,6 @@
|
|||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
"start": "node dist/app.js"
|
"start": "node dist/app.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-rate-limit": "7.5.0",
|
"express-rate-limit": "7.5.0",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+1433
-140
File diff suppressed because it is too large
Load Diff
+1306
-78
File diff suppressed because it is too large
Load Diff
+224
-7
@@ -102,6 +102,12 @@ textarea {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.content-shell {
|
.content-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -114,6 +120,120 @@ textarea {
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero-card {
|
||||||
|
min-height: 280px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copy,
|
||||||
|
.auth-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82));
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button {
|
||||||
|
text-decoration: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 12px 24px rgba(39, 105, 179, 0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button-mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(31, 42, 42, 0.08);
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button-copy small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-icon-svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-icon-apple {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-google {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(66, 133, 244, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-microsoft {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.92));
|
||||||
|
border-color: rgba(0, 164, 239, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-apple {
|
||||||
|
background: linear-gradient(180deg, #1f2328, #111418);
|
||||||
|
border-color: rgba(17, 20, 24, 0.92);
|
||||||
|
color: #f7f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-apple .provider-button-mark {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #f7f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-apple .provider-button-copy small {
|
||||||
|
color: rgba(247, 247, 245, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-button:not(.disabled):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 16px 28px rgba(39, 105, 179, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.stack-grid {
|
.stack-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -202,8 +322,9 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: minmax(220px, 280px);
|
||||||
align-self: end;
|
align-self: end;
|
||||||
|
justify-content: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats article,
|
.hero-stats article,
|
||||||
@@ -228,8 +349,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats article::before,
|
.hero-stats article::before,
|
||||||
.bird-card::before,
|
.bird-card::before {
|
||||||
.chart-card::before {
|
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
@@ -286,6 +406,42 @@ textarea {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-switcher {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-header small,
|
||||||
|
.workspace-switcher-item small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-item.active {
|
||||||
|
background: linear-gradient(135deg, rgba(203, 58, 53, 0.16), rgba(39, 105, 179, 0.18));
|
||||||
|
border-color: rgba(39, 105, 179, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
.bird-list {
|
.bird-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
@@ -298,6 +454,16 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-card-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-copy span {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.bird-avatar,
|
.bird-avatar,
|
||||||
.profile-photo {
|
.profile-photo {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -360,10 +526,6 @@ textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card::before {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-chart-card::before {
|
.overview-chart-card::before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -516,6 +678,23 @@ label {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82));
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-card input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -616,11 +795,48 @@ label {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crop-preview-frame {
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-preview-image {
|
||||||
|
position: absolute;
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.78);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: inset 0 0 0 999px rgba(31, 42, 42, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-preview-frame.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.photo-copy {
|
.photo-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crop-control-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.file-picker input[type="file"] {
|
.file-picker input[type="file"] {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -628,6 +844,7 @@ label {
|
|||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.app-shell,
|
.app-shell,
|
||||||
|
.auth-panel,
|
||||||
.hero-card,
|
.hero-card,
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.forms-grid,
|
.forms-grid,
|
||||||
|
|||||||
Reference in New Issue
Block a user