sveltekit-pocketbase-starter
Advanced tools
Comparing version 0.1.8 to 1.0.0
{ | ||
"name": "sveltekit-pocketbase-starter", | ||
"version": "0.1.8", | ||
"version": "1.0.0", | ||
"author": "Dominick Caponi <d.caponi1@gmail.com>", | ||
"license": "MIT", | ||
"scripts": { | ||
"dev": "vite dev", | ||
"dev": "docker compose -f ./pocketbase/docker-compose.yml up -d && vite dev", | ||
"build": "vite build", | ||
@@ -14,12 +14,17 @@ "preview": "vite preview", | ||
"devDependencies": { | ||
"@sveltejs/adapter-auto": "^2.0.0", | ||
"@sveltejs/adapter-static": "^1.0.0-next.50", | ||
"@sveltejs/adapter-vercel": "^1.0.0-next.88", | ||
"@sveltejs/kit": "^1.20.4", | ||
"svelte": "^4.0.0", | ||
"svelte-check": "^3.4.3", | ||
"@sveltejs/adapter-auto": "^3.0.0", | ||
"@sveltejs/adapter-static": "^3.0.0", | ||
"@sveltejs/adapter-vercel": "^4.0.0", | ||
"@sveltejs/kit": "^2.5.27", | ||
"@types/stripe-v3": "^3.1.29", | ||
"audio-recorder-polyfill": "^0.4.1", | ||
"smui-theme": "^7.0.0-beta.16", | ||
"svelte": "^5.0.0", | ||
"svelte-check": "^4.0.0", | ||
"svelte-preprocess": "^6.0.0", | ||
"tslib": "^2.4.1", | ||
"typescript": "^5.0.0", | ||
"vite": "^4.3.6", | ||
"vitest": "^0.34.3" | ||
"typescript": "^5.5.0", | ||
"vite": "^5.4.4", | ||
"vitest": "^1.0.0", | ||
"@types/jsonwebtoken": "^9.0.7" | ||
}, | ||
@@ -26,0 +31,0 @@ "type": "module", |
@@ -15,7 +15,11 @@ # SvelteKit PocketBase Auth Starter Kit | ||
5. Set "Application name" to whatever you like. "Set Application URL" to `localhost:5173` (we need this for redirect URLs to the SvelteKit pieces later) | ||
6. Follow one or both of the flow setup guides below | ||
7. Create routes in `/routes` following standard SvelteKit developer guidance | ||
6. Add a column called `purchaseIntent` to house the jwt containing info on what product was purchased when stripe calls us back. | ||
7. Add a column for `credits` if you intend to do a la carte type pricing (typical of generative AI type products). | ||
8. Add a column for `subscriptionID` for subscription based services. | ||
1. This value will get automatically nulled out if a user no longer has an active subscription according to Stripe. | ||
9. Follow one or both of the flow setup guides below | ||
10. Create routes in `/routes` following standard SvelteKit developer guidance | ||
1. If you want them to be *protected* i.e. user is logged in to see the page, add the folder to `const protectedRoutes = ['/protected']` in `sveltekit-pocketbase-starter/src/routes/+layout.server.ts` | ||
2. You can also configure the page an un-authed user gets bounced to (in this example its `/login`) | ||
8. If you intend to do e-commerce stuff see the e-commerce section below. You'll need to set up stripe test stuff. If not, ignore and if you really hate it, nuke the `/routes/buy` folder and all the stuff about nonce in `routes/+layout.server.ts`. | ||
11. If you intend to do e-commerce stuff see the e-commerce section below. You'll need to set up stripe test stuff. If not, ignore and if you really hate it, nuke the `/routes/buy` folder and all the stuff about nonce in `routes/+layout.server.ts`. | ||
@@ -79,3 +83,3 @@ ## Adding to Existing Project | ||
* Anyway, stripe lets you create public and secret keys. You'll need a pair for test (usually starting with `sk_test_`) and a pair for production (usually starting with just `sk_`) | ||
* Now you need to fill out all the paperwork with your EIN from the IRS and add all your support emails and terms and stuff. Do that if you got it. Primarily you need an EIN or your social but that only works if you're doing business as a sole proprietor | ||
* Now you need to fill out all the paperwork with your EIN from the IRS and add all your support emails and terms and stuff. Do that if you got it. Primarily you need an EIN or your socia security number but social security number only works if you're doing business as a sole proprietor | ||
* Finally create some products and put their IDs in the `.env.local` (see `.env-example` for a hint) | ||
@@ -87,3 +91,5 @@ 2. next create a long-ish string of random garbage to be your JWT signing secret (see `.env-example`) | ||
3. modify `/routes/buy` to the endpoint what your offerings page is called | ||
4. update `offerings` in `/routes/buy/+page.server.ts` to reflect what you're selling. Give price, label and sku. I left my old stuff in there as an example. | ||
4. Go to Stripe and add products and for each product add a price (and whether its a one-time or subscription aka recurring) | ||
1. For your products, add a description to show that | ||
2. For your products, if you are selling credits, add a metadata field called `credits` and in the value add a number for how many credits the product represents. | ||
5. update the `urls` in `/routes/buy/+page.server.ts` to reflect your prod domains and whatnot. (actually this should be env vars but I'm lazy) | ||
@@ -90,0 +96,0 @@ 6. Make `/routes/buy/+page.svelte` pretty. Offerings should come out from server so just decorate this page, no need for anything fancy. |
import type { Handle } from '@sveltejs/kit'; | ||
import PocketBase from 'pocketbase'; | ||
import { VITE_POCKETBASE_URL } from '$env/static/private'; | ||
import { VITE_POCKETBASE_URL, VITE_HOSTNAME } from '$env/static/private'; | ||
@@ -26,3 +26,3 @@ export const handle: Handle = async ({ event, resolve }) => { | ||
sameSite: isProd ? 'none' : 'lax', | ||
domain: isProd ? ".yoursite.tld" : "localhost" | ||
domain: isProd ? `.${VITE_HOSTNAME}` : "localhost" | ||
}) | ||
@@ -29,0 +29,0 @@ ); |
@@ -6,3 +6,5 @@ import { writable } from 'svelte/store'; | ||
subscriptionID?: string | null; | ||
subscriptionCancelAt?: Date | null; | ||
loggedIn: boolean; | ||
} | ||
export const userStore = writable<UserState>(); |
import { redirect } from '@sveltejs/kit'; | ||
import type { LayoutServerLoad } from './$types'; | ||
import { decodeJwt } from '$lib/jwt'; | ||
import Stripe from 'stripe'; | ||
import type { LayoutServerLoad } from './$types'; | ||
const stripe = new Stripe(import.meta.env['VITE_STRIPE_SECRET_KEY'], { | ||
apiVersion: '2023-08-16', | ||
apiVersion: '2023-10-16', | ||
}); | ||
export const load: LayoutServerLoad = async ({ locals, url }) => { | ||
const protectedRoutes = ['protected'] | ||
const protectedRoutes = ['protected', 'buy'] | ||
if (!locals.pb?.authStore.isValid && protectedRoutes.includes(url.pathname.split("/").filter(Boolean)[0])) { | ||
throw redirect(302, '/login') | ||
redirect(302, '/login'); | ||
} | ||
const currentUserToken = decodeJwt(locals.pb?.authStore.token || ''); | ||
if (currentUserToken){ | ||
let currentUser = await locals.pb?.collection('users').getOne(currentUserToken.id); | ||
const userAuthSession = decodeJwt(locals.pb?.authStore.token || ''); | ||
if (userAuthSession){ | ||
let currentUser = await locals.pb?.collection('users').getOne(userAuthSession.id); | ||
if (currentUser){ | ||
let subscriptionStatus = null; | ||
let subscriptionCancelAt = null; | ||
let subscriptionID = null; | ||
@@ -30,3 +29,3 @@ | ||
if (stripeCustomerResult.data.length > 0){ | ||
const stripeCustomerID = stripeCustomerResult.data[0].id; | ||
const stripeCustomerID: string = stripeCustomerResult.data[0].id; | ||
const subscriptionResult = await stripe.subscriptions.list({ | ||
@@ -37,8 +36,11 @@ customer: stripeCustomerID | ||
subscriptionStatus = subscriptionResult.data[0].status; | ||
if (subscriptionResult.data[0].cancel_at) { | ||
subscriptionCancelAt = new Date(subscriptionResult.data[0].cancel_at * 1000) | ||
} | ||
subscriptionID = subscriptionResult.data[0].id; | ||
await locals.pb?.collection('users').update(currentUserToken.id, { | ||
await locals.pb?.collection('users').update(userAuthSession.id, { | ||
subscriptionID: subscriptionStatus === "active" ? subscriptionID : null | ||
}) | ||
} else { | ||
await locals.pb?.collection('users').update(currentUserToken.id, { | ||
await locals.pb?.collection('users').update(userAuthSession.id, { | ||
subscriptionID: null | ||
@@ -48,10 +50,11 @@ }) | ||
} | ||
if (currentUser.nonce) { | ||
if (currentUser.purchaseIntent) { | ||
const nonce = url.searchParams.get('nonce') | ||
const userToken = decodeJwt(currentUser.nonce) | ||
if (userToken.nonce === nonce) { | ||
await locals.pb?.collection('users').update(currentUserToken.id, { nonce: '', credits: (currentUser.credits + userToken.credits)}); | ||
currentUser = await locals.pb?.collection('users').getOne(currentUserToken.id); | ||
const purchaseIntent = decodeJwt(currentUser.purchaseIntent) | ||
let newUserState = {purchaseIntent: '', credits: currentUser.credits} | ||
if (purchaseIntent.nonce === nonce) { | ||
newUserState = {...newUserState, credits: (currentUser.credits + purchaseIntent.credits)} | ||
} | ||
await locals.pb?.collection('users').update(userAuthSession.id, newUserState); | ||
currentUser = await locals.pb?.collection('users').getOne(userAuthSession.id); | ||
} | ||
@@ -61,4 +64,5 @@ return { | ||
username: currentUser?.name || "Current User", | ||
credits: currentUser?.credits, | ||
subscriptionID: currentUser?.subscriptionID, | ||
credits: currentUser?.credits | ||
subscriptionCancelAt | ||
} | ||
@@ -68,4 +72,7 @@ } | ||
return { | ||
loggedIn: false | ||
loggedIn: false, | ||
subscriptionID: "", | ||
username: "", | ||
credits: 0 | ||
} | ||
}; |
@@ -9,11 +9,7 @@ import jwt from 'jsonwebtoken'; | ||
import { | ||
VITE_STRIPE_ID_BEST_PRODUCT, | ||
VITE_STRIPE_ID_BETTER_PRODUCT, | ||
VITE_STRIPE_ID_GOOD_PRODUCT, | ||
VITE_NONCE_SIGNING_SECRET | ||
VITE_NONCE_SIGNING_SECRET, | ||
VITE_HOSTNAME | ||
} from "$env/static/private"; | ||
export type Choice = { | ||
type: "payment" | "subscription"; | ||
sku: string; | ||
description: string; | ||
@@ -23,15 +19,26 @@ price: number; | ||
stripeID: string; | ||
type: "payment" | "subscription"; | ||
credits: number | null; | ||
} | ||
const stripe = new Stripe(import.meta.env['VITE_STRIPE_SECRET_KEY'], { | ||
apiVersion: '2023-08-16', | ||
apiVersion: '2023-10-16', | ||
}); | ||
const offerings: Array<Choice> = [ | ||
{type: "payment", sku: "good", price: 5, description: "a good product", label: "good", stripeID: VITE_STRIPE_ID_GOOD_PRODUCT}, | ||
{type: "payment", sku: "better", price: 20, description: "a better product", label: "better", stripeID: VITE_STRIPE_ID_BETTER_PRODUCT}, | ||
{type: "payment", sku: "best", price: 30, description: "the best product", label: "best", stripeID: VITE_STRIPE_ID_BEST_PRODUCT}, | ||
{type: "subscription", sku: "subscription", description: "a subscription", price: 20, label: "Monthly Subscription", description: "As many interviews as you like. Billed monthly. Cancel anytime.", credits: 0, stripeID: VITE_STRIPE_ID_SUBSCRIPTION}, | ||
const products = await stripe.products.list({active: true}) | ||
] | ||
const offerings = (await Promise.all(products.data.map(async (product): Promise<Choice | null> => { | ||
if (product.default_price) { | ||
const price = (await stripe.prices.retrieve(product.default_price.toString())) | ||
return { | ||
label: product.name, | ||
stripeID: product.default_price.toString(), | ||
description: product.description ?? "unspecified", | ||
price: (price.unit_amount ?? 0) / 100, | ||
type: price.recurring === null ? "payment" : "subscription", | ||
credits: parseInt(product.metadata.credits ?? 0) | ||
} | ||
} | ||
return null | ||
}))).filter(x => x !== null) | ||
@@ -59,3 +66,3 @@ export const load: PageServerLoad = async () => { | ||
if (!locals.pb?.authStore.isValid){ | ||
throw redirect(301, '/'); | ||
redirect(301, '/'); | ||
} | ||
@@ -67,3 +74,3 @@ const rawData = await request.formData(); | ||
const nonce = generateNonce(); | ||
const nonceToken = jwt.sign({...chosen, nonce}, VITE_NONCE_SIGNING_SECRET); | ||
const purchaseIntent = jwt.sign({...chosen, nonce}, VITE_NONCE_SIGNING_SECRET); | ||
@@ -73,3 +80,3 @@ // pin the nonce to the user. it should match when the user comes back from stripe | ||
const currentUserToken = decodeJwt(locals.pb?.authStore.token || ''); | ||
locals.pb?.collection('users').update(currentUserToken.id, {nonce: nonceToken}); | ||
locals.pb?.collection('users').update(currentUserToken.id, {purchaseIntent}); | ||
@@ -86,10 +93,10 @@ const isProd = process.env.NODE_ENV === 'production' ? true : false; | ||
// you can also have it call some backend service which will redirect the user back to the site when its done doing its thing | ||
success_url: isProd ? `https://yoursite?nonce=${nonce}` : `http://localhost:5173/?nonce=${nonce}`, | ||
cancel_url: isProd ? `https://yoursite` : `http://localhost:5173/`, | ||
success_url: isProd ? `https://${VITE_HOSTNAME}?nonce=${nonce}` : `http://localhost:5173/?nonce=${nonce}`, | ||
cancel_url: isProd ? `https://${VITE_HOSTNAME}` : `http://localhost:5173/`, | ||
automatic_tax: {enabled: true}, | ||
}); | ||
// send user to credit card page so stripe can handle that | ||
throw redirect(303, session.url || 'http://localhost:5173/'); | ||
redirect(303, session.url || 'http://localhost:5173/'); | ||
} | ||
}, | ||
} |
@@ -17,3 +17,3 @@ import { redirect } from '@sveltejs/kit'; | ||
console.log('authy providers'); | ||
throw redirect(303, '/login'); | ||
redirect(303, '/login'); | ||
} | ||
@@ -24,3 +24,3 @@ const provider = authMethods.authProviders.find(p => p.name == providerName); | ||
console.log('Provider not found'); | ||
throw redirect(303, '/login'); | ||
redirect(303, '/login'); | ||
} | ||
@@ -30,3 +30,3 @@ | ||
console.log('state does not match expected', expectedState, state); | ||
throw redirect(303, '/login'); | ||
redirect(303, '/login'); | ||
} | ||
@@ -42,3 +42,3 @@ | ||
throw redirect(303, '/'); | ||
redirect(303, '/'); | ||
}; |
@@ -11,6 +11,6 @@ import { redirect } from '@sveltejs/kit'; | ||
if (locals.pb?.authStore.isValid){ | ||
throw redirect(303, '/') | ||
redirect(303, '/'); | ||
} | ||
} | ||
throw redirect(303, '/login') | ||
redirect(303, '/login'); | ||
} |
@@ -14,3 +14,3 @@ import { fail, redirect, type Cookies } from '@sveltejs/kit'; | ||
if (authToken) { | ||
throw redirect(302, '/') | ||
redirect(302, '/'); | ||
} | ||
@@ -75,6 +75,6 @@ | ||
if(locals.pb?.authStore.isValid){ | ||
cookies.set( | ||
'pb_auth', | ||
locals.pb?.authStore.exportToCookie({ secure: isProd, sameSite: 'lax', httpOnly: true }) | ||
); | ||
/* @migration task: add path argument */ cookies.set( | ||
'pb_auth', | ||
locals.pb?.authStore.exportToCookie({ secure: isProd, sameSite: 'lax', httpOnly: true }) | ||
); | ||
return { success: true } | ||
@@ -81,0 +81,0 @@ } |
@@ -5,3 +5,3 @@ import type { RequestEvent, RequestHandler } from './$types'; | ||
locals.pb?.authStore.clear(); | ||
return new Response(null, {status: 303}); | ||
return new Response(null, {status: 303, headers: {location: "/"}}); | ||
} |
import vercel from '@sveltejs/adapter-vercel'; | ||
import { vitePreprocess } from '@sveltejs/kit/vite'; | ||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | ||
@@ -4,0 +4,0 @@ /** @type {import('@sveltejs/kit').Config} */ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
374775
33
412
1
96
15