├── .eslintrc
├── .gitignore
├── README.md
├── components
├── AuthContent.tsx
├── CreatePostForm.tsx
├── Header.tsx
├── Layout.tsx
├── LogInForm.tsx
├── Nav.tsx
├── ProfileForm.tsx
├── SendPasswordResetEmailForm.tsx
├── SetPasswordForm.tsx
├── SignUpForm.tsx
└── UnAuthContent.tsx
├── hooks
└── useAuth.tsx
├── lib
└── apolloClient.ts
├── next-env.d.ts
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── create-post.tsx
├── forgot-password.tsx
├── index.tsx
├── log-in.tsx
├── log-out.tsx
├── members.tsx
├── profile.tsx
├── set-password.tsx
└── sign-up.tsx
├── public
├── favicon.ico
└── vercel.svg
├── styles
├── Home.module.css
└── globals.css
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Headless WordPress Authentication with Native Cookies
2 |
3 | Next.js app code for this blog post:
4 | https://developers.wpengine.com/blog/headless-wordpress-authentication-native-cookies/
5 |
6 | This is a Next.js application that shows how to authenticate users using WordPress' own native auth cookies.
7 |
8 | ## About its usage on Safari & iOS ⚠️
9 |
10 | Apple doesn't allow cross-site cookies, making the login impossible if you are hosting the app & WordPress on **separate domains**.
11 |
12 | The **short-term** solution is to disable this option from Safari settings:
13 |
14 | `Safari > Settings > Site tracking > Prevent Cross-Site Tracking`.
15 |
16 | It will allow you to use the app, but it won't fix the issue for all other Safari / iOS users...
17 |
18 |
19 | The **long-term** solution is to host both the WordPress (back-end) & the webapp (front-end) on the **same domain** (e.g. each one on a different sub-domain).
20 |
21 | See this related [issue](https://github.com/kellenmace/headless-wordpress-authentication-native-cookies/issues/4) for more informations.
22 |
--------------------------------------------------------------------------------
/components/AuthContent.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, ReactNode } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import useAuth from "../hooks/useAuth";
5 |
6 | export default function AuthContent({ children }: { children: ReactNode }) {
7 | const { loggedIn, loading } = useAuth();
8 | const router = useRouter();
9 |
10 | // Navigate unauthenticated users to Log In page.
11 | useEffect(() => {
12 | if (!loading && !loggedIn) {
13 | router.push('/log-in');
14 | }
15 | }, [loggedIn, loading, router]);
16 |
17 | if (loggedIn) {
18 | return <>{children}>;
19 | }
20 |
21 | return
Loading...
;
22 | }
23 |
--------------------------------------------------------------------------------
/components/CreatePostForm.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, gql } from "@apollo/client";
2 |
3 | const CREATE_POST = gql`
4 | mutation createPost($title: String!, $content: String!) {
5 | createPost(input: {
6 | title: $title
7 | content: $content
8 | status: PUBLISH
9 | }) {
10 | post {
11 | databaseId
12 | }
13 | }
14 | }
15 | `;
16 |
17 | export default function CreatePostForm() {
18 | const [createPost, { data, loading, error }] = useMutation(CREATE_POST);
19 | const wasPostCreated = Boolean(data?.createPost?.post?.databaseId);
20 |
21 | function handleSubmit(event: React.FormEvent) {
22 | event.preventDefault();
23 | const data = new FormData(event.currentTarget);
24 | const values = Object.fromEntries(data);
25 | createPost({
26 | variables: values
27 | }).catch(error => {
28 | console.error(error);
29 | });
30 | }
31 |
32 | if (wasPostCreated) {
33 | return (
34 | Post successfully created.
35 | );
36 | }
37 |
38 | return (
39 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Nav from "./Nav";
2 |
3 | export default function Header() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import React, { ReactNode } from "react";
3 |
4 | import Header from "./Header";
5 |
6 | export default function Layout({ children }: { children: ReactNode }) {
7 | return (
8 | <>
9 |
10 | Headless WP App
11 |
12 |
13 | {children}
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/LogInForm.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useMutation, gql } from "@apollo/client";
3 |
4 | import { GET_USER } from "../hooks/useAuth";
5 |
6 | const LOG_IN = gql`
7 | mutation logIn($login: String!, $password: String!) {
8 | loginWithCookies(input: {
9 | login: $login
10 | password: $password
11 | }) {
12 | status
13 | }
14 | }
15 | `;
16 |
17 | export default function LogInForm() {
18 | const [logIn, { loading, error }] = useMutation(LOG_IN, {
19 | refetchQueries: [
20 | { query: GET_USER }
21 | ],
22 | });
23 | const errorMessage = error?.message || '';
24 | const isEmailValid =
25 | !errorMessage.includes('empty_email') &&
26 | !errorMessage.includes('empty_username') &&
27 | !errorMessage.includes('invalid_email') &&
28 | !errorMessage.includes('invalid_username');
29 | const isPasswordValid =
30 | !errorMessage.includes('empty_password') &&
31 | !errorMessage.includes('incorrect_password');
32 |
33 | function handleSubmit(event: React.FormEvent) {
34 | event.preventDefault();
35 | const data = new FormData(event.currentTarget);
36 | const { email, password } = Object.fromEntries(data);
37 | logIn({
38 | variables: {
39 | login: email,
40 | password,
41 | }
42 | }).catch(error => {
43 | console.error(error);
44 | });
45 | }
46 |
47 | return (
48 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/components/Nav.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import useAuth from "../hooks/useAuth";
4 |
5 | export default function Nav() {
6 | const { loggedIn } = useAuth();
7 |
8 | return (
9 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/components/ProfileForm.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, gql } from "@apollo/client";
2 |
3 | import useAuth, { User } from "../hooks/useAuth";
4 |
5 | const UPDATE_PROFILE = gql`
6 | mutation updateProfile(
7 | $id: ID!
8 | $firstName: String!,
9 | $lastName: String!,
10 | $email: String!
11 | ) {
12 | updateUser(input: {
13 | id: $id
14 | firstName: $firstName
15 | lastName: $lastName
16 | email: $email
17 | }) {
18 | user {
19 | databaseId
20 | }
21 | }
22 | }
23 | `;
24 |
25 | export default function ProfileForm() {
26 | const { user } = useAuth();
27 | const { id, firstName, lastName, email } = user as User;
28 | const [updateProfile, { data, loading, error }] = useMutation(UPDATE_PROFILE);
29 | const wasProfileUpdated = Boolean(data?.updateUser?.user?.databaseId);
30 |
31 | function handleSubmit(event: React.FormEvent) {
32 | event.preventDefault();
33 | const data = new FormData(event.currentTarget);
34 | const values = Object.fromEntries(data);
35 | updateProfile({
36 | variables: { id, ...values, },
37 | }).catch(error => {
38 | console.error(error);
39 | });
40 | }
41 |
42 | return (
43 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/components/SendPasswordResetEmailForm.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, gql } from "@apollo/client";
2 |
3 | const SEND_PASSWORD_RESET_EMAIL = gql`
4 | mutation sendPasswordResetEmail($username: String!) {
5 | sendPasswordResetEmail(
6 | input: { username: $username }
7 | ) {
8 | user {
9 | databaseId
10 | }
11 | }
12 | }
13 | `;
14 |
15 | export default function SendPasswordResetEmailForm() {
16 | const [sendPasswordResetEmail, { loading, error, data }] = useMutation(
17 | SEND_PASSWORD_RESET_EMAIL
18 | );
19 | const wasEmailSent = Boolean(data?.sendPasswordResetEmail?.user?.databaseId);
20 |
21 | function handleSubmit(event: React.FormEvent) {
22 | event.preventDefault();
23 | const data = new FormData(event.currentTarget);
24 | const { email } = Object.fromEntries(data);
25 | sendPasswordResetEmail({
26 | variables: {
27 | username: email,
28 | }
29 | }).catch(error => {
30 | console.error(error);
31 | });
32 | }
33 |
34 | if (wasEmailSent) {
35 | return (
36 | Please check your email. A password reset link has been sent to you.
37 | );
38 | }
39 |
40 | return (
41 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/SetPasswordForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useMutation, gql } from "@apollo/client";
3 | import Link from "next/link";
4 |
5 | const RESET_PASSWORD = gql`
6 | mutation resetUserPassword(
7 | $key: String!
8 | $login: String!
9 | $password: String!
10 | ) {
11 | resetUserPassword(
12 | input: {
13 | key: $key
14 | login: $login
15 | password: $password
16 | }
17 | ) {
18 | user {
19 | databaseId
20 | }
21 | }
22 | }
23 | `;
24 |
25 | interface Props {
26 | resetKey: string;
27 | login: string;
28 | }
29 |
30 | export default function SetPasswordForm({ resetKey: key, login }: Props) {
31 | const [password, setPassword] = useState('');
32 | const [passwordConfirm, setPasswordConfirm] = useState('');
33 | const [clientErrorMessage, setClientErrorMessage] = useState('');
34 | const [resetPassword, { data, loading, error }] = useMutation(RESET_PASSWORD);
35 | const wasPasswordReset = Boolean(data?.resetUserPassword?.user?.databaseId);
36 |
37 | function handleSubmit(event: React.FormEvent) {
38 | event.preventDefault()
39 | const isValid = validate();
40 | if (!isValid) return
41 |
42 | resetPassword({
43 | variables: {
44 | key,
45 | login,
46 | password,
47 | },
48 | }).catch(error => {
49 | console.error(error);
50 | });
51 | }
52 |
53 | function validate() {
54 | setClientErrorMessage('');
55 |
56 | const isPasswordLongEnough = password.length >= 5;
57 | if (!isPasswordLongEnough) {
58 | setClientErrorMessage('Password must be at least 5 characters.');
59 | return false;
60 | }
61 |
62 | const doPasswordsMatch = password === passwordConfirm;
63 | if (!doPasswordsMatch) {
64 | setClientErrorMessage('Passwords must match.');
65 | return false;
66 | }
67 |
68 | return true;
69 | }
70 |
71 | if (wasPasswordReset) {
72 | return (
73 | <>
74 | Your new password has been set.
75 |
76 | Log in
77 |
78 | >
79 | );
80 | }
81 |
82 | return (
83 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/components/SignUpForm.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, gql } from "@apollo/client";
2 | import Link from "next/link";
3 |
4 | const REGISTER_USER = gql`
5 | mutation registerUser(
6 | $email: String!
7 | $firstName: String!
8 | $lastName: String!
9 | ) {
10 | registerUser(
11 | input: {
12 | username: $email
13 | email: $email
14 | firstName: $firstName
15 | lastName: $lastName
16 | }
17 | ) {
18 | user {
19 | databaseId
20 | }
21 | }
22 | }
23 | `;
24 |
25 | export default function SignUpForm() {
26 | const [register, { data, loading, error }] = useMutation(REGISTER_USER);
27 | const wasSignUpSuccessful = Boolean(data?.registerUser?.user?.databaseId);
28 |
29 | function handleSubmit(event: React.FormEvent) {
30 | event.preventDefault();
31 | const data = new FormData(event.currentTarget);
32 | const values = Object.fromEntries(data);
33 | register({
34 | variables: values,
35 | }).catch(error => {
36 | console.error(error);
37 | });
38 | }
39 |
40 | if (wasSignUpSuccessful) {
41 | return (
42 |
43 | Thanks! Check your email – an account confirmation link has been sent to you.
44 |
45 | )
46 | }
47 |
48 | return (
49 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/components/UnAuthContent.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, ReactNode } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import useAuth from "../hooks/useAuth";
5 |
6 | export default function UnAuthContent({ children }: { children: ReactNode }) {
7 | const { loggedIn, loading } = useAuth();
8 | const router = useRouter();
9 |
10 | // Navigate authenticated users to Members page.
11 | useEffect(() => {
12 | if (!loading && loggedIn) {
13 | router.push('/members');
14 | }
15 | }, [loggedIn, loading, router]);
16 |
17 | if (!loggedIn) {
18 | return <>{children}>;
19 | }
20 |
21 | return Loading...
;
22 | }
23 |
--------------------------------------------------------------------------------
/hooks/useAuth.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery, gql, ApolloError } from "@apollo/client";
2 | import React, { createContext, useContext, ReactNode } from "react";
3 |
4 | export interface User {
5 | id: string;
6 | databaseId: number;
7 | firstName: string;
8 | lastName: string;
9 | email: string;
10 | capabilities: string[];
11 | }
12 |
13 | interface AuthData {
14 | loggedIn: boolean;
15 | user?: User,
16 | loading: boolean;
17 | error?: ApolloError;
18 | }
19 |
20 | const DEFAULT_STATE: AuthData = {
21 | loggedIn: false,
22 | user: undefined,
23 | loading: false,
24 | error: undefined,
25 | };
26 |
27 | const AuthContext = createContext(DEFAULT_STATE);
28 |
29 | export const GET_USER = gql`
30 | query getUser {
31 | viewer {
32 | id
33 | databaseId
34 | firstName
35 | lastName
36 | email
37 | capabilities
38 | }
39 | }
40 | `;
41 |
42 | export function AuthProvider({ children }: { children: ReactNode }) {
43 | const { data, loading, error } = useQuery(GET_USER);
44 | const user = data?.viewer;
45 | const loggedIn = Boolean(user);
46 |
47 | const value = {
48 | loggedIn,
49 | user,
50 | loading,
51 | error,
52 | };
53 |
54 | return {children}
55 | }
56 |
57 | const useAuth = () => useContext(AuthContext);
58 |
59 | export default useAuth;
60 |
--------------------------------------------------------------------------------
/lib/apolloClient.ts:
--------------------------------------------------------------------------------
1 | import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
2 |
3 | const link = createHttpLink({
4 | uri: process.env.NEXT_PUBLIC_WORDPRESS_API_URL,
5 | credentials: 'include',
6 | });
7 |
8 | export const client = new ApolloClient({
9 | cache: new InMemoryCache(),
10 | link
11 | });
12 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-with-ts-apollo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@apollo/client": "^3.3.20",
13 | "graphql": "^15.5.1",
14 | "next": "11.0.1",
15 | "react": "17.0.2",
16 | "react-dom": "17.0.2"
17 | },
18 | "devDependencies": {
19 | "@types/react": "17.0.11",
20 | "eslint": "^7.29.0",
21 | "eslint-config-next": "^11.0.1",
22 | "typescript": "4.3.4"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import type { AppProps } from "next/app";
2 | import { ApolloProvider } from "@apollo/client";
3 |
4 | import { client } from "../lib/apolloClient";
5 | import { AuthProvider } from "../hooks/useAuth";
6 | import "../styles/globals.css";
7 |
8 | export default function MyApp({ Component, pageProps }: AppProps) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/pages/create-post.tsx:
--------------------------------------------------------------------------------
1 | import useAuth from "../hooks/useAuth";
2 | import AuthContent from "../components/AuthContent";
3 | import Layout from "../components/Layout";
4 | import CreatePostForm from "../components/CreatePostForm";
5 |
6 | export default function CreatePost() {
7 | const { user } = useAuth();
8 | const canCreatePosts = Boolean(user?.capabilities?.includes('publish_posts'));
9 |
10 | return (
11 |
12 |
13 | Create Post
14 | {canCreatePosts ? (
15 |
16 | ) : (
17 | You don't have the permissions necessary to create posts.
18 | )}
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/pages/forgot-password.tsx:
--------------------------------------------------------------------------------
1 | import Layout from "../components/Layout";
2 | import UnAuthContent from "../components/UnAuthContent";
3 | import SendPasswordResetEmailForm from "../components/SendPasswordResetEmailForm";
4 |
5 | export default function ForgotPassword() {
6 | return (
7 |
8 | Forgot Your Password?
9 |
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 |
3 | import styles from '../styles/Home.module.css';
4 | import Layout from "../components/Layout";
5 |
6 | export default function Home() {
7 | return (
8 |
9 |
10 | Create Next App
11 |
12 |
13 |
14 |
15 |
16 |
17 | Welcome to Next.js!
18 |
19 |
20 |
21 | Get started by editing{' '}
22 | pages/index.js
23 |
24 |
25 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/pages/log-in.tsx:
--------------------------------------------------------------------------------
1 | import Layout from "../components/Layout";
2 | import UnAuthContent from "../components/UnAuthContent";
3 | import LogInForm from "../components/LogInForm";
4 |
5 | export default function LogIn() {
6 | return (
7 |
8 | Log In
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/pages/log-out.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useMutation, gql } from "@apollo/client";
3 |
4 | import { GET_USER } from "../hooks/useAuth";
5 | import Layout from "../components/Layout";
6 |
7 | const LOG_OUT = gql`
8 | mutation logOut {
9 | logout(input: {}) {
10 | status
11 | }
12 | }
13 | `;
14 |
15 | export default function LogOut() {
16 | const [logOut, { called, loading, error, data }] = useMutation(LOG_OUT, {
17 | refetchQueries: [
18 | { query: GET_USER }
19 | ],
20 | });
21 | const loggedOut = Boolean(data?.logout?.status);
22 |
23 | useEffect(() => {
24 | logOut();
25 | }, [logOut]);
26 |
27 | return (
28 |
29 | Log Out
30 | {!called || loading ? (
31 | Logging out...
32 | ) : error ? (
33 | {error.message}
34 | ) : !loggedOut ? (
35 | Unable to log out. Please reload the page and try again.
36 | ) : (
37 | You have been logged out.
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/pages/members.tsx:
--------------------------------------------------------------------------------
1 | import Layout from "../components/Layout";
2 | import AuthContent from "../components/AuthContent";
3 |
4 | export default function MembersContent() {
5 | return (
6 |
7 | Members
8 |
9 | Here is some top-secret members-only content!
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | import AuthContent from "../components/AuthContent";
2 | import Layout from "../components/Layout";
3 | import ProfileForm from "../components/ProfileForm";
4 |
5 | export default function Profile() {
6 | return (
7 |
8 | Profile
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/pages/set-password.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 |
3 | import Layout from "../components/Layout";
4 | import SetPasswordForm from "../components/SetPasswordForm";
5 |
6 | export default function SetPassword() {
7 | const router = useRouter();
8 | const resetKey = String(router.query.key || '');
9 | const login = String(router.query.login || '');
10 |
11 | return (
12 |
13 | Set Password
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/pages/sign-up.tsx:
--------------------------------------------------------------------------------
1 | import Layout from "../components/Layout";
2 | import UnAuthContent from "../components/UnAuthContent";
3 | import SignUpForm from "../components/SignUpForm";
4 |
5 | export default function SignUp() {
6 | return (
7 |
8 | Sign Up
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kellenmace/headless-wordpress-authentication-native-cookies/d68d9c6eefcfbfad9c3fbaeb7e0828c281ed0826/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: 100vh;
3 | padding: 0 0.5rem;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: center;
7 | align-items: center;
8 | height: 100vh;
9 | }
10 |
11 | .main {
12 | padding: 5rem 0;
13 | flex: 1;
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: center;
17 | align-items: center;
18 | }
19 |
20 | .footer {
21 | width: 100%;
22 | height: 100px;
23 | border-top: 1px solid #eaeaea;
24 | display: flex;
25 | justify-content: center;
26 | align-items: center;
27 | }
28 |
29 | .footer a {
30 | display: flex;
31 | justify-content: center;
32 | align-items: center;
33 | flex-grow: 1;
34 | }
35 |
36 | .title a {
37 | color: #0070f3;
38 | text-decoration: none;
39 | }
40 |
41 | .title a:hover,
42 | .title a:focus,
43 | .title a:active {
44 | text-decoration: underline;
45 | }
46 |
47 | .title {
48 | margin: 0;
49 | line-height: 1.15;
50 | font-size: 4rem;
51 | }
52 |
53 | .title,
54 | .description {
55 | text-align: center;
56 | }
57 |
58 | .description {
59 | line-height: 1.5;
60 | font-size: 1.5rem;
61 | }
62 |
63 | .code {
64 | background: #fafafa;
65 | border-radius: 5px;
66 | padding: 0.75rem;
67 | font-size: 1.1rem;
68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
69 | Bitstream Vera Sans Mono, Courier New, monospace;
70 | }
71 |
72 | .grid {
73 | display: flex;
74 | align-items: center;
75 | justify-content: center;
76 | flex-wrap: wrap;
77 | max-width: 800px;
78 | margin-top: 3rem;
79 | }
80 |
81 | .card {
82 | margin: 1rem;
83 | padding: 1.5rem;
84 | text-align: left;
85 | color: inherit;
86 | text-decoration: none;
87 | border: 1px solid var(--color-gray-9);
88 | border-radius: 10px;
89 | transition: color 0.15s ease, border-color 0.15s ease;
90 | width: 45%;
91 | }
92 |
93 | .card:hover,
94 | .card:focus,
95 | .card:active {
96 | color: #0070f3;
97 | border-color: #0070f3;
98 | }
99 |
100 | .card h2 {
101 | margin: 0 0 1rem 0;
102 | font-size: 1.5rem;
103 | }
104 |
105 | .card p {
106 | margin: 0;
107 | font-size: 1.25rem;
108 | line-height: 1.5;
109 | }
110 |
111 | .logo {
112 | height: 1em;
113 | margin-left: 0.5rem;
114 | }
115 |
116 | @media (max-width: 600px) {
117 | .grid {
118 | width: 100%;
119 | flex-direction: column;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --border-radius: 8px;
3 |
4 | /* Colors */
5 | --color-white: #fff;
6 | --color-gray-1: #102a43;
7 | --color-gray-2: #243b53;
8 | --color-gray-3: #334e68;
9 | --color-gray-4: #486581;
10 | --color-gray-5: #627d98;
11 | --color-gray-6: #829ab1;
12 | --color-gray-7: #9fb3c8;
13 | --color-gray-8: #bcccdc;
14 | --color-gray-9: #d9e2ec;
15 | --color-gray-10: #f0f4f8;
16 | --color-yellow-1: #8d2b0b;
17 | --color-yellow-2: #b44d12;
18 | --color-yellow-3: #cb6e17;
19 | --color-yellow-4: #de911d;
20 | --color-yellow-5: #f0b429;
21 | --color-yellow-6: #f7c948;
22 | --color-yellow-7: #fadb5f;
23 | --color-yellow-8: #fce588;
24 | --color-yellow-9: #fff3c4;
25 | --color-yellow-10: #fffbea;
26 |
27 | /* Box Shadows */
28 | --box-shadow-1: rgba(0, 0, 0, 0.2) 0px 2px 1px -1px,
29 | rgba(0, 0, 0, 0.14) 0px 1px 1px 0px, rgba(0, 0, 0, 0.12) 0px 1px 3px 0px;
30 | --box-shadow-2: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
31 | rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
32 | --box-shadow-3: rgba(0, 0, 0, 0.2) 0px 3px 3px -2px,
33 | rgba(0, 0, 0, 0.14) 0px 3px 4px 0px, rgba(0, 0, 0, 0.12) 0px 1px 8px 0px;
34 | --box-shadow-4: rgba(0, 0, 0, 0.2) 0px 2px 4px -1px,
35 | rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px;
36 | --box-shadow-5: rgba(0, 0, 0, 0.2) 0px 3px 5px -1px,
37 | rgba(0, 0, 0, 0.14) 0px 5px 8px 0px, rgba(0, 0, 0, 0.12) 0px 1px 14px 0px;
38 | --box-shadow-6: rgba(0, 0, 0, 0.2) 0px 3px 5px -1px,
39 | rgba(0, 0, 0, 0.14) 0px 6px 10px 0px, rgba(0, 0, 0, 0.12) 0px 1px 18px 0px;
40 | --box-shadow-7: rgba(0, 0, 0, 0.2) 0px 4px 5px -2px,
41 | rgba(0, 0, 0, 0.14) 0px 7px 10px 1px, rgba(0, 0, 0, 0.12) 0px 2px 16px 1px;
42 | --box-shadow-8: rgba(0, 0, 0, 0.2) 0px 5px 5px -3px,
43 | rgba(0, 0, 0, 0.14) 0px 8px 10px 1px, rgba(0, 0, 0, 0.12) 0px 3px 14px 2px;
44 | --box-shadow-9: rgba(0, 0, 0, 0.2) 0px 5px 6px -3px,
45 | rgba(0, 0, 0, 0.14) 0px 9px 12px 1px, rgba(0, 0, 0, 0.12) 0px 3px 16px 2px;
46 | --box-shadow-10: rgba(0, 0, 0, 0.2) 0px 6px 6px -3px,
47 | rgba(0, 0, 0, 0.14) 0px 10px 14px 1px, rgba(0, 0, 0, 0.12) 0px 4px 18px 3px;
48 | --box-shadow-11: rgba(0, 0, 0, 0.2) 0px 6px 7px -4px,
49 | rgba(0, 0, 0, 0.14) 0px 11px 15px 1px, rgba(0, 0, 0, 0.12) 0px 4px 20px 3px;
50 | --box-shadow-12: rgba(0, 0, 0, 0.2) 0px 7px 8px -4px,
51 | rgba(0, 0, 0, 0.14) 0px 12px 17px 2px, rgba(0, 0, 0, 0.12) 0px 5px 22px 4px;
52 | --box-shadow-13: rgba(0, 0, 0, 0.2) 0px 7px 8px -4px,
53 | rgba(0, 0, 0, 0.14) 0px 13px 19px 2px, rgba(0, 0, 0, 0.12) 0px 5px 24px 4px;
54 | --box-shadow-14: rgba(0, 0, 0, 0.2) 0px 7px 9px -4px,
55 | rgba(0, 0, 0, 0.14) 0px 14px 21px 2px, rgba(0, 0, 0, 0.12) 0px 5px 26px 4px;
56 | --box-shadow-15: rgba(0, 0, 0, 0.2) 0px 8px 9px -5px,
57 | rgba(0, 0, 0, 0.14) 0px 15px 22px 2px, rgba(0, 0, 0, 0.12) 0px 6px 28px 5px;
58 | }
59 |
60 | html,
61 | body {
62 | padding: 0;
63 | margin: 0;
64 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
65 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
66 | background-color: #f0f4f8;
67 | color: var(--color-gray-2);
68 | }
69 |
70 | h1 {
71 | font-size: 2.1rem;
72 | line-height: 1.2;
73 | }
74 | h2 {
75 | font-size: 1.7rem;
76 | line-height: 1.3;
77 | }
78 | h3 {
79 | font-size: 1.5rem;
80 | line-height: 1.4;
81 | }
82 | h4 {
83 | font-size: 1.3rem;
84 | line-height: 1.5;
85 | }
86 | h5 {
87 | font-size: 1rem;
88 | line-height: 1.6;
89 | }
90 | h6 {
91 | font-size: 0.8rem;
92 | line-height: 1.7;
93 | }
94 |
95 | a {
96 | color: inherit;
97 | }
98 |
99 | * {
100 | box-sizing: border-box;
101 | }
102 |
103 | main {
104 | margin: 0 auto 4rem;
105 | max-width: 1000px;
106 | padding: 4rem 1rem 0;
107 | }
108 |
109 | @media (min-width: 850px) {
110 | main {
111 | padding-left: 4rem;
112 | padding-right: 4rem;
113 | }
114 | }
115 |
116 | .header {
117 | border-bottom: 2px solid var(--color-gray-9);
118 | }
119 |
120 | .nav {
121 | margin: 0;
122 | padding: 0;
123 | display: flex;
124 | }
125 |
126 | .nav li {
127 | list-style: none;
128 | }
129 |
130 | .nav a {
131 | display: block;
132 | padding: 2rem;
133 | text-decoration: none;
134 | }
135 |
136 | .error-message {
137 | color: var(--color-yellow-1);
138 | }
139 |
140 | fieldset {
141 | border: none;
142 | margin: 0;
143 | padding: 0;
144 | }
145 |
146 | form label {
147 | display: block;
148 | font-size: 0.9rem;
149 | }
150 |
151 | form input,
152 | form textarea {
153 | border: 2px solid var(--color-gray-9);
154 | padding: 0.5rem;
155 | border-radius: var(--border-radius);
156 | margin: 0.1rem 0 1rem;
157 | width: 100%;
158 | max-width: 300px;
159 | }
160 |
161 | form input:focus {
162 | outline: none;
163 | border-color: var(--color-yellow-6);
164 | }
165 |
166 | button {
167 | display: block;
168 | font-weight: 700;
169 | border: none;
170 | border-radius: var(--border-radius);
171 | padding: 14px 18px;
172 | background-color: var(--color-yellow-6);
173 | color: var(--color-yellow-3);
174 | cursor: pointer;
175 | }
176 |
177 | button:hover,
178 | button:active {
179 | color: var(--color-yellow-2);
180 | }
181 |
182 | .profile-update-confirmation {
183 | display: block;
184 | background: var(--color-gray-9);
185 | border-radius: var(--border-radius);
186 | padding: 1rem;
187 | }
188 |
189 | .forgot-password-link {
190 | display: block;
191 | font-size: 0.7rem;
192 | margin-bottom: 1rem;
193 | }
194 |
195 | .account-sign-up-message {
196 | margin-top: 2.5rem;
197 | }
198 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve"
16 | },
17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
18 | "exclude": ["node_modules"]
19 | }
20 |
--------------------------------------------------------------------------------