mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-11 21:39:03 +02:00
Organized 'users' functionality vertically. (#226)
This commit is contained in:
parent
0e4e76ae88
commit
a348a85660
@ -84,7 +84,7 @@
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -96,7 +94,10 @@
|
||||
@@ -345,7 +343,10 @@
|
||||
email String? @unique
|
||||
username String? @unique
|
||||
lastActiveTimestamp DateTime @default(now())
|
||||
|
@ -20,10 +20,10 @@
|
||||
+ 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, GithubUrl } from '../../common';
|
||||
import { UserMenuItems } from '../components/UserMenuItems';
|
||||
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||
|
||||
export default function LandingPage() {
|
||||
|
@ -1,6 +1,6 @@
|
||||
--- template/app/src/server/queries.ts
|
||||
+++ opensaas-sh/app/src/server/queries.ts
|
||||
@@ -110,6 +110,7 @@
|
||||
--- template/app/src/user/operations.ts
|
||||
+++ opensaas-sh/app/src/user/operations.ts
|
||||
@@ -80,6 +80,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin: args.isAdmin,
|
||||
@ -8,7 +8,7 @@
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
@@ -150,6 +151,7 @@
|
||||
@@ -120,6 +121,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
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 }
|
||||
page AccountPage {
|
||||
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>
|
||||
```
|
||||
|
||||
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() {
|
||||
const handleClick = () => {
|
||||
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)
|
||||
```
|
||||
|
||||
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.
|
||||
│ ├── auth/ # All auth-related pages/components and logic.
|
||||
│ ├── 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.client # Dev environment variables for your client code.
|
||||
├── .prettierrc # Prettier configuration.
|
||||
├── tailwind.config.js # TailwindCSS configuration.
|
||||
├── tailwind.config.js # TailwindCSS configuration.
|
||||
├── package.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.
|
||||
├── actions.ts # Your server-side write/mutation functions.
|
||||
├── queries.ts # Your server-side read functions.
|
||||
└── utils.ts
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
## 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
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
@ -218,12 +195,6 @@ page 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
|
||||
route AdminRoute { path: "/admin", to: DashboardPage }
|
||||
page DashboardPage {
|
||||
@ -306,16 +277,6 @@ action updateTask {
|
||||
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 {
|
||||
fn: import { getGptResponses } from "@src/server/queries.js",
|
||||
entities: [User, GptResponse]
|
||||
@ -331,11 +292,6 @@ query getDailyStats {
|
||||
entities: [User, DailyStats]
|
||||
}
|
||||
|
||||
query getPaginatedUsers {
|
||||
fn: import { getPaginatedUsers } from "@src/server/queries.js",
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
job emailChecker {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
@ -359,6 +315,52 @@ job dailyStatsJob {
|
||||
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
|
||||
route PricingPageRoute { path: "/pricing", to: PricingPage }
|
||||
page PricingPage {
|
||||
@ -384,7 +386,6 @@ api stripeWebhook {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
||||
//#region File Upload
|
||||
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }
|
||||
page FileUploadPage {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { type AuthUser } from 'wasp/auth/types';
|
||||
import DarkModeSwitcher from './DarkModeSwitcher';
|
||||
import MessageButton from './MessageButton';
|
||||
import DropdownUser from '../../components/DropdownUser';
|
||||
import DropdownUser from '../../../user/DropdownUser';
|
||||
import { cn } from '../../cn';
|
||||
|
||||
const Header = (props: {
|
||||
|
@ -6,10 +6,10 @@ import { BiLogIn } from 'react-icons/bi';
|
||||
import { AiFillCloseCircle } from 'react-icons/ai';
|
||||
import { HiBars3 } from 'react-icons/hi2';
|
||||
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 DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||
import { UserMenuItems } from '../components/UserMenuItems';
|
||||
|
||||
const navigation = [
|
||||
{ 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 openSaasBanner from '../static/open-saas-banner.png';
|
||||
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 { UserMenuItems } from '../components/UserMenuItems';
|
||||
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
|
||||
|
||||
export default function LandingPage() {
|
||||
|
@ -2,8 +2,6 @@ import { type User, type Task } from 'wasp/entities';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import {
|
||||
type GenerateGptResponse,
|
||||
type UpdateCurrentUser,
|
||||
type UpdateUserById,
|
||||
type CreateTask,
|
||||
type DeleteTask,
|
||||
type UpdateTask,
|
||||
@ -19,8 +17,6 @@ function setupOpenAI() {
|
||||
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
}
|
||||
|
||||
|
||||
|
||||
type GptPayload = {
|
||||
hours: string;
|
||||
};
|
||||
@ -221,38 +217,3 @@ export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, con
|
||||
|
||||
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 {
|
||||
type GetGptResponses,
|
||||
type GetDailyStats,
|
||||
type GetPaginatedUsers,
|
||||
type GetAllTasksByUser,
|
||||
} from 'wasp/server/operations';
|
||||
import { type SubscriptionStatus } from '../payment/plans';
|
||||
|
||||
type DailyStatsWithSources = DailyStats & {
|
||||
sources: PageViewSource[];
|
||||
@ -71,107 +69,3 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
|
||||
|
||||
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 { SubscriptionStatus } from '../../payment/plans';
|
||||
import { prettyPaymentPlanName, parsePaymentPlanId } from '../../payment/plans';
|
||||
import {
|
||||
type SubscriptionStatus,
|
||||
prettyPaymentPlanName,
|
||||
parsePaymentPlanId
|
||||
} from '../payment/plans';
|
||||
import { Link } from 'wasp/client/router';
|
||||
import { logout } from 'wasp/client/auth';
|
||||
import { z } from 'zod';
|
@ -2,7 +2,7 @@ import { type User } from 'wasp/entities';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CgProfile } from 'react-icons/cg';
|
||||
import { UserMenuItems } from './UserMenuItems';
|
||||
import { cn } from '../cn';
|
||||
import { cn } from '../client/cn';
|
||||
|
||||
const DropdownUser = ({ user }: { user: Partial<User> }) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
@ -3,7 +3,7 @@ import { type User } from 'wasp/entities';
|
||||
import { logout } from 'wasp/client/auth';
|
||||
import { MdOutlineSpaceDashboard } from 'react-icons/md';
|
||||
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 }) => {
|
||||
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,
|
||||
};
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user