Skip to content

Commit 9526db8

Browse files
committed
feat: registration + manage account
1 parent ca4927e commit 9526db8

File tree

10 files changed

+512
-46
lines changed

10 files changed

+512
-46
lines changed

app/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
NEXT_PUBLIC_SUPABASE_URL=https://mvrfvzcivgabojxddwtk.supabase.co
2+
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im12cmZ2emNpdmdhYm9qeGRkd3RrIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NDc0NDgyOTksImV4cCI6MTk2MzAyNDI5OX0.-vizz4hPlmuRai3UckLKRj_ESosf63JfX8R1NF1r04I

app/lib/supabase.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from "react";
2+
import { createClient, SupabaseClient } from "@supabase/supabase-js";
3+
import { Auth } from "@supabase/ui";
4+
5+
const SupabaseClientContext = React.createContext<SupabaseClient | null>(null);
6+
7+
export function SupabaseProvider(props: { children: React.ReactElement }) {
8+
const [client] = React.useState(() =>
9+
createClient(
10+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
11+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
12+
)
13+
);
14+
15+
return (
16+
<SupabaseClientContext.Provider value={client}>
17+
<Auth.UserContextProvider supabaseClient={client}>
18+
{props.children}
19+
</Auth.UserContextProvider>
20+
</SupabaseClientContext.Provider>
21+
);
22+
}
23+
24+
export function useSupabaseClient(): SupabaseClient {
25+
const client = React.useContext(SupabaseClientContext);
26+
if (client === null) {
27+
throw new Error(
28+
"Supabase client not provided via context.\n" +
29+
"Did you forget to wrap your component tree with SupabaseProvider?"
30+
);
31+
}
32+
return client;
33+
}

app/lib/urql.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from "react";
2+
import { createClient, Provider } from "urql";
3+
import { useSupabaseClient } from "./supabase";
4+
5+
export function UrqlProvider(props: { children: React.ReactElement }) {
6+
const supabaseClient = useSupabaseClient();
7+
8+
function getHeaders(): Record<string, string> {
9+
const headers: Record<string, string> = {
10+
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
11+
};
12+
const authorization = supabaseClient.auth.session()?.access_token;
13+
14+
if (authorization) {
15+
headers["authorization"] = `Bearer ${authorization}`;
16+
}
17+
18+
return headers;
19+
}
20+
21+
const [client] = React.useState(() =>
22+
createClient({
23+
url: `${process.env.NEXT_PUBLIC_SUPABASE_URL!}/rest/v1/rpc/graphql`,
24+
fetchOptions: {
25+
headers: getHeaders(),
26+
},
27+
})
28+
);
29+
return <Provider value={client}>{props.children}</Provider>;
30+
}

app/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12+
"@supabase/supabase-js": "1.31.2",
13+
"@supabase/ui": "0.36.4",
14+
"graphql": "16.3.0",
1215
"next": "12.1.0",
1316
"react": "17.0.2",
14-
"react-dom": "17.0.2"
17+
"react-dom": "17.0.2",
18+
"urql": "2.2.0"
1519
},
1620
"devDependencies": {
1721
"@types/node": "17.0.23",

app/pages/_app.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import "../styles/globals.css";
22
import type { AppProps } from "next/app";
3+
import { UrqlProvider } from "../lib/urql";
4+
import { SupabaseProvider } from "../lib/supabase";
35

46
function MyApp({ Component, pageProps }: AppProps) {
5-
return <Component {...pageProps} />;
7+
return (
8+
<SupabaseProvider>
9+
<UrqlProvider>
10+
<Component {...pageProps} />
11+
</UrqlProvider>
12+
</SupabaseProvider>
13+
);
614
}
715

816
export default MyApp;

app/pages/account.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import React from "react";
2+
import { NextPage } from "next";
3+
import { useRouter } from "next/router";
4+
import { Auth, Loading } from "@supabase/ui";
5+
import { useSupabaseClient } from "../lib/supabase";
6+
import { DocumentType, gql } from "../gql";
7+
import { CombinedError, useMutation, useQuery } from "urql";
8+
9+
const UserProfileQuery = gql(/* GraphQL */ `
10+
query UserProfileQuery($profileId: UUID!) {
11+
profileCollection(filter: { id: { eq: $profileId } }) {
12+
edges {
13+
node {
14+
id
15+
username
16+
website
17+
}
18+
}
19+
}
20+
}
21+
`);
22+
23+
const Account: NextPage = () => {
24+
const session = Auth.useUser();
25+
const [profileQuery] = useQuery({
26+
query: UserProfileQuery,
27+
variables: { profileId: session.user?.id },
28+
pause: session.user === null,
29+
});
30+
const router = useRouter();
31+
32+
const profile = profileQuery.data?.profileCollection?.edges?.[0].node;
33+
34+
React.useEffect(() => {
35+
if (session.user === null || (profileQuery.data && profile === null)) {
36+
router.replace("/login");
37+
}
38+
}, [session.user, profile, profileQuery.data]);
39+
40+
if (profile) {
41+
return <AccountForm profile={profile} />;
42+
}
43+
44+
return null;
45+
};
46+
47+
const ProfileFragment = gql(/* GraphQL */ `
48+
fragment AccountProfileFragment on Profile {
49+
id
50+
username
51+
website
52+
}
53+
`);
54+
55+
const UpdateProfileMutation = gql(/* GraphQL */ `
56+
mutation updateProfile(
57+
$userId: UUID!
58+
$newUsername: String!
59+
$newWebsite: String!
60+
) {
61+
updateProfileCollection(
62+
filter: { id: { eq: $userId } }
63+
set: { username: $newUsername, website: $newWebsite }
64+
) {
65+
affectedCount
66+
records {
67+
id
68+
username
69+
website
70+
}
71+
}
72+
}
73+
`);
74+
75+
function extractExpectedGraphQLErrors(
76+
error: CombinedError | undefined
77+
): null | string {
78+
if (error === undefined) {
79+
return null;
80+
}
81+
82+
for (const graphQLError of error.graphQLErrors) {
83+
if (graphQLError.message.includes("usernamelength")) {
84+
return "Username must have a minimum length of 3 characters.";
85+
}
86+
if (graphQLError.message.includes("Profile_username_key")) {
87+
return "The name is already taken.";
88+
}
89+
}
90+
91+
return null;
92+
}
93+
94+
function AccountForm(props: { profile: DocumentType<typeof ProfileFragment> }) {
95+
const supabaseClient = useSupabaseClient();
96+
const [username, setUsername] = React.useState(props.profile.username ?? "");
97+
const [website, setWebsite] = React.useState(props.profile.website ?? "");
98+
const [updateProfileMutation, updateProfile] = useMutation(
99+
UpdateProfileMutation
100+
);
101+
102+
const errorState = extractExpectedGraphQLErrors(updateProfileMutation.error);
103+
104+
return (
105+
<>
106+
<div className="form-widget">
107+
<div>
108+
<label htmlFor="username">Name</label>
109+
<input
110+
id="username"
111+
type="text"
112+
value={username}
113+
onChange={(e) => setUsername(e.target.value)}
114+
/>
115+
</div>
116+
<div>
117+
<label htmlFor="website">Website</label>
118+
<input
119+
id="website"
120+
type="website"
121+
value={website || ""}
122+
onChange={(e) => setWebsite(e.target.value)}
123+
/>
124+
</div>
125+
126+
<div>
127+
<button
128+
className="button block primary"
129+
onClick={() =>
130+
updateProfile({
131+
userId: props.profile.id,
132+
newUsername: username,
133+
newWebsite: website,
134+
})
135+
}
136+
disabled={updateProfileMutation.fetching}
137+
>
138+
{updateProfileMutation.fetching ? "Loading ..." : "Update"}
139+
</button>
140+
</div>
141+
142+
<div>{errorState}</div>
143+
144+
<div>
145+
<button
146+
className="button block"
147+
onClick={() => supabaseClient.auth.signOut()}
148+
>
149+
Sign Out
150+
</button>
151+
</div>
152+
</div>
153+
</>
154+
);
155+
}
156+
157+
export default Account;

app/pages/index.tsx

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,46 @@
11
import type { NextPage } from "next";
22
import Head from "next/head";
33
import Image from "next/image";
4+
import { useQuery } from "urql";
5+
import { gql } from "../gql";
46
import styles from "../styles/Home.module.css";
57

8+
const FeedQuery = gql(/* GraphQL */ `
9+
query FeedQuery {
10+
feed: postCollection {
11+
edges {
12+
post: node {
13+
id
14+
title
15+
url
16+
upVotes: voteCollection(filter: { direction: { eq: "UP" } }) {
17+
totalCount
18+
}
19+
downVotes: voteCollection(filter: { direction: { eq: "DOWN" } }) {
20+
totalCount
21+
}
22+
comments: commentCollection {
23+
edges {
24+
node {
25+
id
26+
message
27+
profile {
28+
id
29+
username
30+
avatarUrl
31+
}
32+
}
33+
}
34+
commentCount: totalCount
35+
}
36+
}
37+
}
38+
}
39+
}
40+
`);
41+
642
const Home: NextPage = () => {
43+
const [data] = useQuery({ query: FeedQuery });
744
return (
845
<div className={styles.container}>
946
<Head>
@@ -13,44 +50,9 @@ const Home: NextPage = () => {
1350
</Head>
1451

1552
<main className={styles.main}>
16-
<h1 className={styles.title}>
17-
Welcome to <a href="https://nextjs.org">Next.js!</a>
18-
</h1>
19-
20-
<p className={styles.description}>
21-
Get started by editing{" "}
22-
<code className={styles.code}>pages/index.tsx</code>
23-
</p>
24-
25-
<div className={styles.grid}>
26-
<a href="https://nextjs.org/docs" className={styles.card}>
27-
<h2>Documentation &rarr;</h2>
28-
<p>Find in-depth information about Next.js features and API.</p>
29-
</a>
30-
31-
<a href="https://nextjs.org/learn" className={styles.card}>
32-
<h2>Learn &rarr;</h2>
33-
<p>Learn about Next.js in an interactive course with quizzes!</p>
34-
</a>
35-
36-
<a
37-
href="https://github.com/vercel/next.js/tree/canary/examples"
38-
className={styles.card}
39-
>
40-
<h2>Examples &rarr;</h2>
41-
<p>Discover and deploy boilerplate example Next.js projects.</p>
42-
</a>
43-
44-
<a
45-
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
46-
className={styles.card}
47-
>
48-
<h2>Deploy &rarr;</h2>
49-
<p>
50-
Instantly deploy your Next.js site to a public URL with Vercel.
51-
</p>
52-
</a>
53-
</div>
53+
<pre>
54+
<code>{JSON.stringify(data?.data, null, 2)}</code>
55+
</pre>
5456
</main>
5557

5658
<footer className={styles.footer}>

app/pages/login.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from "react";
2+
import type { NextPage } from "next";
3+
import { Auth } from "@supabase/ui";
4+
import { useSupabaseClient } from "../lib/supabase";
5+
6+
const LogIn: NextPage = () => {
7+
const { user } = Auth.useUser();
8+
const supabaseClient = useSupabaseClient();
9+
10+
return user ? (
11+
<pre>
12+
<code>{JSON.stringify(user, null, 2)}</code>
13+
<button
14+
onClick={() => {
15+
supabaseClient.auth.signOut();
16+
}}
17+
>
18+
Log out
19+
</button>
20+
</pre>
21+
) : (
22+
<Auth supabaseClient={supabaseClient} providers={["github"]} />
23+
);
24+
};
25+
26+
export default LogIn;

graphql.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"schema": ["./graphql/schema/schema.graphql.graphql"],
2+
"schema": ["./graphql/schema/schema.graphql"],
33
"documents": ["./app/**/*.{graphql,js,ts,jsx,tsx}"]
44
}

0 commit comments

Comments
 (0)