mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-24 14:26:45 +01:00
Organized 'users' functionality vertically. (#226)
This commit is contained in:
@@ -84,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -96,7 +94,10 @@
|
@@ -345,7 +343,10 @@
|
||||||
email String? @unique
|
email String? @unique
|
||||||
username String? @unique
|
username String? @unique
|
||||||
lastActiveTimestamp DateTime @default(now())
|
lastActiveTimestamp DateTime @default(now())
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
+ footerNavigation,
|
+ footerNavigation,
|
||||||
+ testimonials,
|
+ testimonials,
|
||||||
+} from './contentSections';
|
+} from './contentSections';
|
||||||
import DropdownUser from '../components/DropdownUser';
|
import DropdownUser from '../../user/DropdownUser';
|
||||||
|
import { UserMenuItems } from '../../user/UserMenuItems';
|
||||||
-import { DocsUrl } from '../../common';
|
-import { DocsUrl } from '../../common';
|
||||||
+import { DocsUrl, GithubUrl } from '../../common';
|
+import { DocsUrl, GithubUrl } from '../../common';
|
||||||
import { UserMenuItems } from '../components/UserMenuItems';
|
|
||||||
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
--- template/app/src/server/queries.ts
|
--- template/app/src/user/operations.ts
|
||||||
+++ opensaas-sh/app/src/server/queries.ts
|
+++ opensaas-sh/app/src/user/operations.ts
|
||||||
@@ -110,6 +110,7 @@
|
@@ -80,6 +80,7 @@
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin: args.isAdmin,
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
@@ -150,6 +151,7 @@
|
@@ -120,6 +121,7 @@
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin: args.isAdmin,
|
||||||
@@ -26,7 +26,7 @@ To control which pages require users to be authenticated to access them, you can
|
|||||||
route AccountRoute { path: "/account", to: AccountPage }
|
route AccountRoute { path: "/account", to: AccountPage }
|
||||||
page AccountPage {
|
page AccountPage {
|
||||||
authRequired: true,
|
authRequired: true,
|
||||||
component: import Account from "@src/client/app/AccountPage"
|
component: import Account from "@src/user/AccountPage"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Da
|
|||||||
REACT_APP_STRIPE_CUSTOMER_PORTAL=<your-test-customer-portal-link>
|
REACT_APP_STRIPE_CUSTOMER_PORTAL=<your-test-customer-portal-link>
|
||||||
```
|
```
|
||||||
|
|
||||||
Your Stripe customer portal link is imported into `src/client/app/AccountPage.tsx` and used to redirect users to the Stripe customer portal when they click the `Manage Subscription` button.
|
Your Stripe customer portal link is imported into `src/user/AccountPage.tsx` and used to redirect users to the Stripe customer portal when they click the `Manage Subscription` button.
|
||||||
|
|
||||||
```tsx title="src/client/app/AccountPage.tsx" {5} "import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL"
|
```tsx title="src/user/AccountPage.tsx" {5} "import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL"
|
||||||
function CustomerPortalButton() {
|
function CustomerPortalButton() {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
try {
|
try {
|
||||||
@@ -115,4 +115,4 @@ You should see a message like this:
|
|||||||
> Ready! You are using Stripe API Version [2023-08-16]. Your webhook signing secret is whsec_8a... (^C to quit)
|
> Ready! You are using Stripe API Version [2023-08-16]. Your webhook signing secret is whsec_8a... (^C to quit)
|
||||||
```
|
```
|
||||||
|
|
||||||
copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.
|
copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.
|
||||||
|
|||||||
@@ -63,11 +63,12 @@ If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below
|
|||||||
│ ├── server/ # Your server code (NodeJS) goes here.
|
│ ├── server/ # Your server code (NodeJS) goes here.
|
||||||
│ ├── auth/ # All auth-related pages/components and logic.
|
│ ├── auth/ # All auth-related pages/components and logic.
|
||||||
│ ├── file-upload/ # Logic for uploading files to S3.
|
│ ├── file-upload/ # Logic for uploading files to S3.
|
||||||
│ └── payment/ # Logic for handling Stripe payments and webhooks.
|
│ ├── payment/ # Logic for handling Stripe payments and webhooks.
|
||||||
|
│ └── user/ # Logic related to users and their accounts.
|
||||||
├── .env.server # Dev environment variables for your server code.
|
├── .env.server # Dev environment variables for your server code.
|
||||||
├── .env.client # Dev environment variables for your client code.
|
├── .env.client # Dev environment variables for your client code.
|
||||||
├── .prettierrc # Prettier configuration.
|
├── .prettierrc # Prettier configuration.
|
||||||
├── tailwind.config.js # TailwindCSS configuration.
|
├── tailwind.config.js # TailwindCSS configuration.
|
||||||
├── package.json
|
├── package.json
|
||||||
├── package-lock.json
|
├── package-lock.json
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ All you have to do is define your server-side functions in the `main.wasp` file,
|
|||||||
├── workers # Functions that run in the background as Wasp Jobs, e.g. daily stats calculation.
|
├── workers # Functions that run in the background as Wasp Jobs, e.g. daily stats calculation.
|
||||||
├── actions.ts # Your server-side write/mutation functions.
|
├── actions.ts # Your server-side write/mutation functions.
|
||||||
├── queries.ts # Your server-side read functions.
|
├── queries.ts # Your server-side read functions.
|
||||||
└── utils.ts
|
└── utils.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Main Features
|
## Main Features
|
||||||
|
|||||||
@@ -85,29 +85,6 @@ app OpenSaaS {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
entity User {=psl
|
|
||||||
id String @id @default(uuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
email String? @unique
|
|
||||||
username String? @unique
|
|
||||||
lastActiveTimestamp DateTime @default(now())
|
|
||||||
isAdmin Boolean @default(false)
|
|
||||||
|
|
||||||
stripeId String? @unique
|
|
||||||
checkoutSessionId String?
|
|
||||||
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
|
|
||||||
subscriptionPlan String? // 'hobby', 'pro'
|
|
||||||
sendEmail Boolean @default(false)
|
|
||||||
datePaid DateTime?
|
|
||||||
credits Int @default(3)
|
|
||||||
|
|
||||||
gptResponses GptResponse[]
|
|
||||||
contactFormMessages ContactFormMessage[]
|
|
||||||
tasks Task[]
|
|
||||||
files File[]
|
|
||||||
psl=}
|
|
||||||
|
|
||||||
entity GptResponse {=psl
|
entity GptResponse {=psl
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -218,12 +195,6 @@ page DemoAppPage {
|
|||||||
component: import DemoAppPage from "@src/client/app/DemoAppPage"
|
component: import DemoAppPage from "@src/client/app/DemoAppPage"
|
||||||
}
|
}
|
||||||
|
|
||||||
route AccountRoute { path: "/account", to: AccountPage }
|
|
||||||
page AccountPage {
|
|
||||||
authRequired: true,
|
|
||||||
component: import Account from "@src/client/app/AccountPage"
|
|
||||||
}
|
|
||||||
|
|
||||||
//#region Admin Pages
|
//#region Admin Pages
|
||||||
route AdminRoute { path: "/admin", to: DashboardPage }
|
route AdminRoute { path: "/admin", to: DashboardPage }
|
||||||
page DashboardPage {
|
page DashboardPage {
|
||||||
@@ -306,16 +277,6 @@ action updateTask {
|
|||||||
entities: [Task]
|
entities: [Task]
|
||||||
}
|
}
|
||||||
|
|
||||||
action updateCurrentUser {
|
|
||||||
fn: import { updateCurrentUser } from "@src/server/actions.js",
|
|
||||||
entities: [User]
|
|
||||||
}
|
|
||||||
|
|
||||||
action updateUserById {
|
|
||||||
fn: import { updateUserById } from "@src/server/actions.js",
|
|
||||||
entities: [User]
|
|
||||||
}
|
|
||||||
|
|
||||||
query getGptResponses {
|
query getGptResponses {
|
||||||
fn: import { getGptResponses } from "@src/server/queries.js",
|
fn: import { getGptResponses } from "@src/server/queries.js",
|
||||||
entities: [User, GptResponse]
|
entities: [User, GptResponse]
|
||||||
@@ -331,11 +292,6 @@ query getDailyStats {
|
|||||||
entities: [User, DailyStats]
|
entities: [User, DailyStats]
|
||||||
}
|
}
|
||||||
|
|
||||||
query getPaginatedUsers {
|
|
||||||
fn: import { getPaginatedUsers } from "@src/server/queries.js",
|
|
||||||
entities: [User]
|
|
||||||
}
|
|
||||||
|
|
||||||
job emailChecker {
|
job emailChecker {
|
||||||
executor: PgBoss,
|
executor: PgBoss,
|
||||||
perform: {
|
perform: {
|
||||||
@@ -359,6 +315,52 @@ job dailyStatsJob {
|
|||||||
entities: [User, DailyStats, Logs, PageViewSource]
|
entities: [User, DailyStats, Logs, PageViewSource]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#region User
|
||||||
|
entity User {=psl
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
email String? @unique
|
||||||
|
username String? @unique
|
||||||
|
lastActiveTimestamp DateTime @default(now())
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
|
||||||
|
stripeId String? @unique
|
||||||
|
checkoutSessionId String?
|
||||||
|
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
|
||||||
|
subscriptionPlan String? // 'hobby', 'pro'
|
||||||
|
sendEmail Boolean @default(false)
|
||||||
|
datePaid DateTime?
|
||||||
|
credits Int @default(3)
|
||||||
|
|
||||||
|
gptResponses GptResponse[]
|
||||||
|
contactFormMessages ContactFormMessage[]
|
||||||
|
tasks Task[]
|
||||||
|
files File[]
|
||||||
|
psl=}
|
||||||
|
|
||||||
|
route AccountRoute { path: "/account", to: AccountPage }
|
||||||
|
page AccountPage {
|
||||||
|
authRequired: true,
|
||||||
|
component: import Account from "@src/user/AccountPage"
|
||||||
|
}
|
||||||
|
|
||||||
|
query getPaginatedUsers {
|
||||||
|
fn: import { getPaginatedUsers } from "@src/user/operations",
|
||||||
|
entities: [User]
|
||||||
|
}
|
||||||
|
|
||||||
|
action updateCurrentUser {
|
||||||
|
fn: import { updateCurrentUser } from "@src/user/operations",
|
||||||
|
entities: [User]
|
||||||
|
}
|
||||||
|
|
||||||
|
action updateUserById {
|
||||||
|
fn: import { updateUserById } from "@src/user/operations",
|
||||||
|
entities: [User]
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
//#region Payment
|
//#region Payment
|
||||||
route PricingPageRoute { path: "/pricing", to: PricingPage }
|
route PricingPageRoute { path: "/pricing", to: PricingPage }
|
||||||
page PricingPage {
|
page PricingPage {
|
||||||
@@ -384,7 +386,6 @@ api stripeWebhook {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
||||||
//#region File Upload
|
//#region File Upload
|
||||||
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }
|
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }
|
||||||
page FileUploadPage {
|
page FileUploadPage {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type AuthUser } from 'wasp/auth/types';
|
import { type AuthUser } from 'wasp/auth/types';
|
||||||
import DarkModeSwitcher from './DarkModeSwitcher';
|
import DarkModeSwitcher from './DarkModeSwitcher';
|
||||||
import MessageButton from './MessageButton';
|
import MessageButton from './MessageButton';
|
||||||
import DropdownUser from '../../components/DropdownUser';
|
import DropdownUser from '../../../user/DropdownUser';
|
||||||
import { cn } from '../../cn';
|
import { cn } from '../../cn';
|
||||||
|
|
||||||
const Header = (props: {
|
const Header = (props: {
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { BiLogIn } from 'react-icons/bi';
|
|||||||
import { AiFillCloseCircle } from 'react-icons/ai';
|
import { AiFillCloseCircle } from 'react-icons/ai';
|
||||||
import { HiBars3 } from 'react-icons/hi2';
|
import { HiBars3 } from 'react-icons/hi2';
|
||||||
import logo from '../static/logo.png';
|
import logo from '../static/logo.png';
|
||||||
import DropdownUser from './DropdownUser';
|
import DropdownUser from '../../user/DropdownUser';
|
||||||
|
import { UserMenuItems } from '../../user/UserMenuItems';
|
||||||
import { DocsUrl, BlogUrl } from '../../common';
|
import { DocsUrl, BlogUrl } from '../../common';
|
||||||
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||||
import { UserMenuItems } from '../components/UserMenuItems';
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() },
|
{ name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() },
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { BiLogIn } from 'react-icons/bi';
|
|||||||
import logo from '../static/logo.png';
|
import logo from '../static/logo.png';
|
||||||
import openSaasBanner from '../static/open-saas-banner.png';
|
import openSaasBanner from '../static/open-saas-banner.png';
|
||||||
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
|
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
|
||||||
import DropdownUser from '../components/DropdownUser';
|
import DropdownUser from '../../user/DropdownUser';
|
||||||
|
import { UserMenuItems } from '../../user/UserMenuItems';
|
||||||
import { DocsUrl } from '../../common';
|
import { DocsUrl } from '../../common';
|
||||||
import { UserMenuItems } from '../components/UserMenuItems';
|
|
||||||
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { type User, type Task } from 'wasp/entities';
|
|||||||
import { HttpError } from 'wasp/server';
|
import { HttpError } from 'wasp/server';
|
||||||
import {
|
import {
|
||||||
type GenerateGptResponse,
|
type GenerateGptResponse,
|
||||||
type UpdateCurrentUser,
|
|
||||||
type UpdateUserById,
|
|
||||||
type CreateTask,
|
type CreateTask,
|
||||||
type DeleteTask,
|
type DeleteTask,
|
||||||
type UpdateTask,
|
type UpdateTask,
|
||||||
@@ -19,8 +17,6 @@ function setupOpenAI() {
|
|||||||
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type GptPayload = {
|
type GptPayload = {
|
||||||
hours: string;
|
hours: string;
|
||||||
};
|
};
|
||||||
@@ -221,38 +217,3 @@ export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, con
|
|||||||
|
|
||||||
return task;
|
return task;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUserById: UpdateUserById<{ id: string; data: Partial<User> }, User> = async (
|
|
||||||
{ id, data },
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new HttpError(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.user.isAdmin) {
|
|
||||||
throw new HttpError(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await context.entities.User.update({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedUser;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new HttpError(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.entities.User.update({
|
|
||||||
where: {
|
|
||||||
id: context.user.id,
|
|
||||||
},
|
|
||||||
data: user,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { HttpError } from 'wasp/server';
|
|||||||
import {
|
import {
|
||||||
type GetGptResponses,
|
type GetGptResponses,
|
||||||
type GetDailyStats,
|
type GetDailyStats,
|
||||||
type GetPaginatedUsers,
|
|
||||||
type GetAllTasksByUser,
|
type GetAllTasksByUser,
|
||||||
} from 'wasp/server/operations';
|
} from 'wasp/server/operations';
|
||||||
import { type SubscriptionStatus } from '../payment/plans';
|
|
||||||
|
|
||||||
type DailyStatsWithSources = DailyStats & {
|
type DailyStatsWithSources = DailyStats & {
|
||||||
sources: PageViewSource[];
|
sources: PageViewSource[];
|
||||||
@@ -71,107 +69,3 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
|
|||||||
|
|
||||||
return { dailyStats, weeklyStats };
|
return { dailyStats, weeklyStats };
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersInput = {
|
|
||||||
skip: number;
|
|
||||||
cursor?: number | undefined;
|
|
||||||
emailContains?: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
subscriptionStatus?: SubscriptionStatus[];
|
|
||||||
};
|
|
||||||
type GetPaginatedUsersOutput = {
|
|
||||||
users: Pick<
|
|
||||||
User,
|
|
||||||
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
|
|
||||||
>[];
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
|
|
||||||
args,
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
if (!context.user?.isAdmin) {
|
|
||||||
throw new HttpError(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
|
|
||||||
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null)
|
|
||||||
let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as string[] | undefined
|
|
||||||
|
|
||||||
const queryResults = await context.entities.User.findMany({
|
|
||||||
skip: args.skip,
|
|
||||||
take: 10,
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{
|
|
||||||
email: {
|
|
||||||
contains: args.emailContains || undefined,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
isAdmin: args.isAdmin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
in: subscriptionStatusStrings,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
equals: hasNotSubscribed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
username: true,
|
|
||||||
isAdmin: true,
|
|
||||||
lastActiveTimestamp: true,
|
|
||||||
subscriptionStatus: true,
|
|
||||||
stripeId: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
id: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalUserCount = await context.entities.User.count({
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{
|
|
||||||
email: {
|
|
||||||
contains: args.emailContains || undefined,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
isAdmin: args.isAdmin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
in: subscriptionStatusStrings,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
equals: hasNotSubscribed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const totalPages = Math.ceil(totalUserCount / 10);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: queryResults,
|
|
||||||
totalPages,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { User } from 'wasp/entities';
|
import type { User } from 'wasp/entities';
|
||||||
import type { SubscriptionStatus } from '../../payment/plans';
|
import {
|
||||||
import { prettyPaymentPlanName, parsePaymentPlanId } from '../../payment/plans';
|
type SubscriptionStatus,
|
||||||
|
prettyPaymentPlanName,
|
||||||
|
parsePaymentPlanId
|
||||||
|
} from '../payment/plans';
|
||||||
import { Link } from 'wasp/client/router';
|
import { Link } from 'wasp/client/router';
|
||||||
import { logout } from 'wasp/client/auth';
|
import { logout } from 'wasp/client/auth';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -2,7 +2,7 @@ import { type User } from 'wasp/entities';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { CgProfile } from 'react-icons/cg';
|
import { CgProfile } from 'react-icons/cg';
|
||||||
import { UserMenuItems } from './UserMenuItems';
|
import { UserMenuItems } from './UserMenuItems';
|
||||||
import { cn } from '../cn';
|
import { cn } from '../client/cn';
|
||||||
|
|
||||||
const DropdownUser = ({ user }: { user: Partial<User> }) => {
|
const DropdownUser = ({ user }: { user: Partial<User> }) => {
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
@@ -3,7 +3,7 @@ import { type User } from 'wasp/entities';
|
|||||||
import { logout } from 'wasp/client/auth';
|
import { logout } from 'wasp/client/auth';
|
||||||
import { MdOutlineSpaceDashboard } from 'react-icons/md';
|
import { MdOutlineSpaceDashboard } from 'react-icons/md';
|
||||||
import { TfiDashboard } from 'react-icons/tfi';
|
import { TfiDashboard } from 'react-icons/tfi';
|
||||||
import { cn } from '../cn';
|
import { cn } from '../client/cn';
|
||||||
|
|
||||||
export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial<User>; setMobileMenuOpen?: any }) => {
|
export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial<User>; setMobileMenuOpen?: any }) => {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
147
template/app/src/user/operations.ts
Normal file
147
template/app/src/user/operations.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
type UpdateCurrentUser,
|
||||||
|
type UpdateUserById,
|
||||||
|
type GetPaginatedUsers,
|
||||||
|
} from 'wasp/server/operations';
|
||||||
|
import { type User } from 'wasp/entities';
|
||||||
|
import { HttpError } from 'wasp/server';
|
||||||
|
import { type SubscriptionStatus } from '../payment/plans';
|
||||||
|
|
||||||
|
export const updateUserById: UpdateUserById<{ id: string; data: Partial<User> }, User> = async (
|
||||||
|
{ id, data },
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new HttpError(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.user.isAdmin) {
|
||||||
|
throw new HttpError(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await context.entities.User.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new HttpError(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.entities.User.update({
|
||||||
|
where: {
|
||||||
|
id: context.user.id,
|
||||||
|
},
|
||||||
|
data: user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetPaginatedUsersInput = {
|
||||||
|
skip: number;
|
||||||
|
cursor?: number | undefined;
|
||||||
|
emailContains?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
subscriptionStatus?: SubscriptionStatus[];
|
||||||
|
};
|
||||||
|
type GetPaginatedUsersOutput = {
|
||||||
|
users: Pick<
|
||||||
|
User,
|
||||||
|
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
|
||||||
|
>[];
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
|
||||||
|
args,
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
if (!context.user?.isAdmin) {
|
||||||
|
throw new HttpError(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
|
||||||
|
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null)
|
||||||
|
let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as string[] | undefined
|
||||||
|
|
||||||
|
const queryResults = await context.entities.User.findMany({
|
||||||
|
skip: args.skip,
|
||||||
|
take: 10,
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
contains: args.emailContains || undefined,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
isAdmin: args.isAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
subscriptionStatus: {
|
||||||
|
in: subscriptionStatusStrings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscriptionStatus: {
|
||||||
|
equals: hasNotSubscribed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
isAdmin: true,
|
||||||
|
lastActiveTimestamp: true,
|
||||||
|
subscriptionStatus: true,
|
||||||
|
stripeId: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
id: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUserCount = await context.entities.User.count({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
contains: args.emailContains || undefined,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
isAdmin: args.isAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
subscriptionStatus: {
|
||||||
|
in: subscriptionStatusStrings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscriptionStatus: {
|
||||||
|
equals: hasNotSubscribed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const totalPages = Math.ceil(totalUserCount / 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: queryResults,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user