Merge pull request #1377 from SkynetLabs/accounts-api-refactor

upgrade accounts to new api (autologin)
This commit is contained in:
Ivaylo Novakov 2021-12-09 14:30:29 +01:00 committed by GitHub
commit 5b4105df45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1623 additions and 1681 deletions

7
.gitignore vendored
View File

@ -53,7 +53,6 @@ typings/
# dotenv environment variable files # dotenv environment variable files
.env* .env*
./docker/kratos/config/kratos.yml
# Mac files # Mac files
.DS_Store .DS_Store
@ -95,8 +94,8 @@ docker/kratos/cr_certs/*.key
# Oathkeeper JWKS signing token # Oathkeeper JWKS signing token
docker/kratos/oathkeeper/id_token.jwks.json docker/kratos/oathkeeper/id_token.jwks.json
/docker/kratos/config/kratos.yml docker/kratos/config/kratos.yml
# Setup-script log files # Setup-script log files
/setup-scripts/serverload.log setup-scripts/serverload.log
/setup-scripts/serverload.json setup-scripts/serverload.json

View File

@ -1,6 +1,7 @@
version: "3.7" version: "3.7"
x-logging: &default-logging x-logging:
&default-logging
driver: json-file driver: json-file
options: options:
max-size: "10m" max-size: "10m"
@ -29,20 +30,23 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
- SKYNET_DB_HOST=${SKYNET_DB_HOST} - ACCOUNTS_EMAIL_URI=${ACCOUNTS_EMAIL_URI}
- SKYNET_DB_PORT=${SKYNET_DB_PORT} - ACCOUNTS_JWKS_FILE=/conf/jwks.json
- SKYNET_DB_USER=${SKYNET_DB_USER}
- SKYNET_DB_PASS=${SKYNET_DB_PASS}
- COOKIE_DOMAIN=${COOKIE_DOMAIN} - COOKIE_DOMAIN=${COOKIE_DOMAIN}
- COOKIE_HASH_KEY=${COOKIE_HASH_KEY} - COOKIE_HASH_KEY=${COOKIE_HASH_KEY}
- COOKIE_ENC_KEY=${COOKIE_ENC_KEY} - COOKIE_ENC_KEY=${COOKIE_ENC_KEY}
- PORTAL_DOMAIN=${PORTAL_DOMAIN}
- SERVER_DOMAIN=${SERVER_DOMAIN}
- SKYNET_DB_HOST=${SKYNET_DB_HOST:-mongo}
- SKYNET_DB_PORT=${SKYNET_DB_PORT:-27017}
- SKYNET_DB_USER=${SKYNET_DB_USER}
- SKYNET_DB_PASS=${SKYNET_DB_PASS}
- STRIPE_API_KEY=${STRIPE_API_KEY} - STRIPE_API_KEY=${STRIPE_API_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- SKYNET_ACCOUNTS_LOG_LEVEL=${SKYNET_ACCOUNTS_LOG_LEVEL} - SKYNET_ACCOUNTS_LOG_LEVEL=${SKYNET_ACCOUNTS_LOG_LEVEL:-info}
- KRATOS_ADDR=${KRATOS_ADDR}
- OATHKEEPER_ADDR=${OATHKEEPER_ADDR}
volumes: volumes:
- ./docker/accounts/conf:/accounts/conf - ./docker/data/accounts:/data
- ./docker/accounts/conf:/conf
expose: expose:
- 3000 - 3000
networks: networks:
@ -50,50 +54,6 @@ services:
ipv4_address: 10.10.10.70 ipv4_address: 10.10.10.70
depends_on: depends_on:
- mongo - mongo
- oathkeeper
kratos-migrate:
image: oryd/kratos:v0.5.5-alpha.1
container_name: kratos-migrate
restart: "no"
logging: *default-logging
environment:
- DSN=cockroach://root@cockroach:26257/defaultdb?max_conns=20&max_idle_conns=4&sslmode=verify-full&sslcert=/certs/node.crt&sslkey=/certs/node.key&sslrootcert=/certs/ca.crt
- SQA_OPT_OUT=true
volumes:
- ./docker/kratos/config:/etc/config/kratos
- ./docker/data/cockroach/sqlite:/var/lib/sqlite
- ./docker/kratos/cr_certs:/certs
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
networks:
shared:
ipv4_address: 10.10.10.80
depends_on:
- cockroach
kratos:
image: oryd/kratos:v0.5.5-alpha.1
container_name: kratos
restart: unless-stopped
logging: *default-logging
expose:
- 4433 # public
- 4434 # admin
environment:
- DSN=cockroach://root@cockroach:26257/defaultdb?max_conns=20&max_idle_conns=4&sslmode=verify-full&sslcert=/certs/node.crt&sslkey=/certs/node.key&sslrootcert=/certs/ca.crt
- LOG_LEVEL=trace
- SERVE_PUBLIC_BASE_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public/
- SQA_OPT_OUT=true
command: serve -c /etc/config/kratos/kratos.yml
volumes:
- ./docker/kratos/config:/etc/config/kratos
- ./docker/data/cockroach/sqlite:/var/lib/sqlite
- ./docker/kratos/cr_certs:/certs
networks:
shared:
ipv4_address: 10.10.10.81
depends_on:
- kratos-migrate
dashboard: dashboard:
build: build:
@ -107,35 +67,16 @@ services:
environment: environment:
- NEXT_PUBLIC_SKYNET_PORTAL_API=${SKYNET_PORTAL_API} - NEXT_PUBLIC_SKYNET_PORTAL_API=${SKYNET_PORTAL_API}
- NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL} - NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL}
- NEXT_PUBLIC_KRATOS_BROWSER_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
- NEXT_PUBLIC_KRATOS_PUBLIC_URL=http://oathkeeper:4455/.ory/kratos/public
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
volumes:
- ./docker/data/dashboard/.next:/usr/app/.next
networks: networks:
shared: shared:
ipv4_address: 10.10.10.85 ipv4_address: 10.10.10.85
expose: expose:
- 3000 - 3000
depends_on: depends_on:
- oathkeeper - mongo
oathkeeper:
image: oryd/oathkeeper:v0.38
container_name: oathkeeper
expose:
- 4455
- 4456
command: serve proxy -c "/etc/config/oathkeeper/oathkeeper.yml"
environment:
- LOG_LEVEL=debug
volumes:
- ./docker/kratos/oathkeeper:/etc/config/oathkeeper
restart: on-failure
logging: *default-logging
networks:
shared:
ipv4_address: 10.10.10.83
depends_on:
- kratos
cockroach: cockroach:
image: cockroachdb/cockroach:v20.2.3 image: cockroachdb/cockroach:v20.2.3

View File

@ -1,5 +1,5 @@
FROM golang:1.16.7 FROM golang:1.16.7
LABEL maintainer="NebulousLabs <devs@nebulous.tech>" LABEL maintainer="SkynetLabs <devs@siasky.net>"
ENV GOOS linux ENV GOOS linux
ENV GOARCH amd64 ENV GOARCH amd64

View File

@ -1,2 +0,0 @@
This directory needs to contain all certificates needed by this cockroachdb node. Those can be generated by the steps
outlined in the README in the root directory, under "Setting up CockroachDB".

View File

@ -1,31 +0,0 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"recovery": {
"via": "email"
}
}
}
},
"required": ["email"],
"additionalProperties": true
}
}
}

View File

@ -1,86 +0,0 @@
version: v0.5.5-alpha.1
dsn: memory
serve:
public:
base_url: http://127.0.0.1/
cors:
enabled: true
admin:
base_url: http://127.0.0.1/admin/
selfservice:
default_browser_return_url: http://127.0.0.1/
whitelisted_return_urls:
- http://127.0.0.1/
methods:
password:
enabled: true
flows:
error:
ui_url: http://127.0.0.1/error
settings:
ui_url: http://127.0.0.1/settings
privileged_session_max_age: 15m
recovery:
enabled: true
ui_url: http://127.0.0.1/recovery
verification:
enabled: true
ui_url: http://127.0.0.1/verify
after:
default_browser_return_url: http://127.0.0.1/
logout:
after:
default_browser_return_url: http://127.0.0.1/auth/login
login:
ui_url: http://127.0.0.1/auth/login
lifespan: 10m
registration:
lifespan: 10m
ui_url: http://127.0.0.1/auth/registration
after:
password:
hooks:
- hook: session
log:
level: debug
format: text
leak_sensitive_values: true
password:
max_breaches: 100
secrets:
cookie:
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
session:
cookie:
domain: account.siasky.net
lifespan: "720h"
hashers:
argon2:
parallelism: 1
memory: 131072
iterations: 2
salt_length: 16
key_length: 16
identity:
default_schema_url: file:///etc/config/kratos/identity.schema.json
courier:
smtp:
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true

View File

@ -1,37 +0,0 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"website": {
"type": "object"
}
},
"required": ["website", "email"],
"additionalProperties": false
}
}
}

View File

@ -1,17 +0,0 @@
local claims = {
email_verified: false
} + std.extVar('claims');
{
identity: {
traits: {
// Allowing unverified email addresses enables account
// enumeration attacks, especially if the value is used for
// e.g. verification or as a password login identifier.
//
// Therefore we only return the email if it (a) exists and (b) is marked verified
// by GitHub.
[if "email" in claims && claims.email_verified then "email" else null]: claims.email,
},
},
}

View File

@ -1,7 +0,0 @@
This directory needs to contain all certificates needed by this cockroachdb node. Those can be generated by the steps
outlined in the README in the root directory, under "Setting up CockroachDB".
The only difference between the files here and those under
`docker/cockroach/certs` is that the files here need to be readable by anyone, while the files under `cockroach` need to
have their original access rights
(all \*.key files should be 600 instead of 644 there).

View File

@ -1,116 +0,0 @@
- id: "ory:kratos:public"
upstream:
preserve_host: true
url: "http://kratos:4433"
strip_path: /.ory/kratos/public
match:
url: "http://oathkeeper:4455/.ory/kratos/public/<**>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
- id: "dashboard:anonymous"
upstream:
preserve_host: true
url: "http://dashboard:3000"
match:
url: "http://oathkeeper:4455/<{_next/**,auth/**,recovery,verify,error,favicon.ico}{/,}>"
methods:
- GET
authenticators:
- handler: anonymous
authorizer:
handler: allow
mutators:
- handler: noop
- id: "dashboard:protected"
upstream:
preserve_host: true
url: "http://dashboard:3000"
match:
url: "http://oathkeeper:4455/<{,api/**,settings,uploads,downloads,payments}>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: id_token
- handler: header
config:
headers:
X-User: "{{ print .Subject }}"
errors:
- handler: redirect
config:
to: http://127.0.0.1/auth/login
- id: "accounts:anonymous"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{health,stripe/prices,stripe/webhook}>"
methods:
- GET
- POST
authenticators:
- handler: anonymous
authorizer:
handler: allow
mutators:
- handler: noop
- id: "accounts:public"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{user/limits}>"
methods:
- GET
authenticators:
- handler: cookie_session
- handler: noop
authorizer:
handler: allow
mutators:
- handler: id_token
- id: "accounts:protected"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{login,logout,user,user/uploads,user/uploads/*,user/downloads,user/stats}>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: id_token
errors:
- handler: redirect
config:
to: http://127.0.0.1/auth/login

View File

@ -1,94 +0,0 @@
log:
level: debug
format: json
serve:
proxy:
cors:
enabled: true
allowed_origins:
- "*"
allowed_methods:
- POST
- GET
- PUT
- PATCH
- DELETE
allowed_headers:
- Authorization
- Content-Type
exposed_headers:
- Content-Type
allow_credentials: true
debug: true
errors:
fallback:
- json
handlers:
redirect:
enabled: true
config:
to: http://127.0.0.1/auth/login
when:
- error:
- unauthorized
- forbidden
request:
header:
accept:
- text/html
json:
enabled: true
config:
verbose: true
access_rules:
matching_strategy: glob
repositories:
- file:///etc/config/oathkeeper/access-rules.yml
authenticators:
anonymous:
enabled: true
config:
subject: guest
cookie_session:
enabled: true
config:
check_session_url: http://kratos:4433/sessions/whoami
preserve_path: true
extra_from: "@this"
subject_from: "identity.id"
only:
- ory_kratos_session
noop:
enabled: true
authorizers:
allow:
enabled: true
mutators:
noop:
enabled: true
header:
enabled: true
config:
headers:
X-User: "{{ print .Subject }}"
id_token:
enabled: true
config:
issuer_url: http://oathkeeper:4455/
jwks_url: file:///etc/config/oathkeeper/id_token.jwks.json
ttl: 720h
claims: |
{
"session": {{ .Extra | toJson }}
}

View File

@ -5,6 +5,32 @@ include /etc/nginx/conf.d/include/ssl-settings;
include /etc/nginx/conf.d/include/init-optional-variables; include /etc/nginx/conf.d/include/init-optional-variables;
location / { location / {
proxy_redirect http://127.0.0.1/ https://$host/; proxy_pass http://dashboard:3000;
proxy_pass http://oathkeeper:4455; }
location /api/stripe/billing {
proxy_pass http://dashboard:3000;
}
location /api/stripe/checkout {
proxy_pass http://dashboard:3000;
}
location /api {
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/register {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/login {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
} }

View File

@ -1,4 +0,0 @@
NEXT_PUBLIC_SKYNET_PORTAL_API=https://siasky.net
NEXT_PUBLIC_SKYNET_DASHBOARD_URL=https://account.siasky.net
NEXT_PUBLIC_KRATOS_BROWSER_URL=https://account.siasky.net/.ory/kratos/public
NEXT_PUBLIC_KRATOS_PUBLIC_URL=https://account.siasky.net/.ory/kratos/public

View File

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-html-link-for-pages": "off"
}
}

View File

@ -10,6 +10,6 @@ RUN yarn --frozen-lockfile
COPY public ./public COPY public ./public
COPY src ./src COPY src ./src
COPY styles ./styles COPY styles ./styles
COPY postcss.config.js tailwind.config.js ./ COPY .eslintrc.json postcss.config.js tailwind.config.js ./
CMD ["sh", "-c", "env | grep -E 'NEXT_PUBLIC|KRATOS|STRIPE' > .env.local && yarn build && yarn start"] CMD ["sh", "-c", "env | grep -E 'NEXT_PUBLIC|STRIPE' > .env.local && yarn build && yarn start"]

View File

@ -8,11 +8,10 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@fontsource/metropolis": "4.5.0", "@fontsource/sora": "4.5.0",
"@ory/kratos-client": "0.5.4-alpha.1", "@fontsource/source-sans-pro": "4.5.0",
"@stripe/react-stripe-js": "1.6.0", "@stripe/react-stripe-js": "1.6.0",
"@stripe/stripe-js": "1.21.1", "@stripe/stripe-js": "1.21.1",
"@tailwindcss/forms": "0.3.4",
"autoprefixer": "10.4.0", "autoprefixer": "10.4.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"copy-text-to-clipboard": "^3.0.1", "copy-text-to-clipboard": "^3.0.1",
@ -21,7 +20,7 @@
"fast-levenshtein": "3.0.0", "fast-levenshtein": "3.0.0",
"formik": "2.2.9", "formik": "2.2.9",
"http-status-codes": "2.1.4", "http-status-codes": "2.1.4",
"ky": "0.25.1", "ky": "0.28.7",
"next": "12.0.3", "next": "12.0.3",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"postcss": "8.3.11", "postcss": "8.3.11",
@ -31,13 +30,16 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-toastify": "8.1.0", "react-toastify": "8.1.0",
"skynet-js": "3.0.2", "skynet-js": "3.0.2",
"stripe": "8.186.1", "stripe": "8.188.0",
"superagent": "6.1.0", "superagent": "6.1.0",
"swr": "1.0.1", "swr": "1.0.1",
"tailwindcss": "2.2.19",
"yup": "0.32.11" "yup": "0.32.11"
}, },
"resolutions": { "devDependencies": {
"axios": "0.21.4" "@tailwindcss/forms": "0.3.4",
"@tailwindcss/typography": "0.4.1",
"eslint": "<8.0.0",
"eslint-config-next": "12.0.3",
"tailwindcss": "2.2.19"
} }
} }

View File

@ -1,25 +1,44 @@
import * as React from "react";
import { useFormik, getIn, setIn } from "formik"; import { useFormik, getIn, setIn } from "formik";
import classnames from "classnames"; import classnames from "classnames";
import SelfServiceMessages from "./SelfServiceMessages"; import SelfServiceMessages from "./SelfServiceMessages";
export default function SelfServiceForm({ flow, config, fieldsConfig, title, button = "Submit" }) { export default function SelfServiceForm({ fieldsConfig, onSubmit, title, validationSchema = null, button = "Submit" }) {
const fields = config.fields const [messages, setMessages] = React.useState([]);
.filter((field) => !field.name.startsWith("traits.name")) // drop name fields const fields = fieldsConfig.sort((a, b) => (a.position < b.position ? -1 : 1));
.map((field) => ({ ...field, ...fieldsConfig[field.name] }))
.sort((a, b) => (a.position < b.position ? -1 : 1));
const formik = useFormik({ const formik = useFormik({
initialValues: fields.reduce((acc, field) => setIn(acc, field.name, field.value ?? ""), {}), initialValues: fields.reduce((acc, field) => setIn(acc, field.name, field.value ?? ""), {}),
validationSchema,
onSubmit: async (values) => {
if (!formik.isValid) return;
setMessages([]);
try {
await onSubmit(values);
} catch (error) {
if (error.response) {
const data = await error.response.json();
if (data.message) {
setMessages((messages) => [...messages, { type: "error", text: data.message }]);
}
} else {
setMessages((messages) => [...messages, { type: "error", text: error.toString() }]);
}
}
},
}); });
return ( return (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
{title && <h3 className="pb-5 text-lg leading-6 font-medium text-gray-900">{title}</h3>} {title && <h3 className="pb-5 text-lg leading-6 font-medium text-gray-900">{title}</h3>}
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" action={config.action} method={config.method}> <form className="space-y-6" onSubmit={formik.handleSubmit}>
{fields.map((field) => ( {fields.map((field) => (
<div key={field.name} className={classnames({ hidden: field.type === "hidden" })}> <div key={field.name} className={classnames({ hidden: field.type === "hidden" })}>
<label htmlFor={field.name} className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor={field.name} className="block text-sm font-medium text-gray-700 mb-1">
{fieldsConfig[field.name]?.label ?? field.name} {field.label ?? field.name}
</label> </label>
<div> <div>
<input <input
@ -27,61 +46,23 @@ export default function SelfServiceForm({ flow, config, fieldsConfig, title, but
name={field.name} name={field.name}
type={field.type} type={field.type}
autoComplete={field.autoComplete} autoComplete={field.autoComplete}
required={field.required}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
value={getIn(formik.values, field.name) ?? ""} value={getIn(formik.values, field.name) ?? ""}
className={classnames( className={classnames(
"appearance-none block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none sm:text-sm", "appearance-none block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none sm:text-sm",
{ {
"border-gray-300 placeholder-gray-400 focus:ring-green-500 focus:border-green-500": !Boolean( "border-gray-300 placeholder-gray-400 focus:ring-green-500 focus:border-green-500": !(
field?.messages?.length formik.errors[field.name] && formik.touched[field.name]
), ),
"border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500": "border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500":
Boolean(field?.messages?.length), formik.errors[field.name] && formik.touched[field.name],
} }
)} )}
/> />
<SelfServiceMessages messages={field.messages} /> {formik.errors[field.name] && formik.touched[field.name] && (
<p className="mt-2 text-xs text-red-600">{formik.errors[field.name]}</p>
{field.checks && (
<div className="mt-4">
<ul className="space-y-1">
{field.checks.map((check, index) => (
<li
key={index}
className={
check.validate(formik.values, field.name) ? "text-green-600 font-medium" : "text-gray-600"
}
>
<div className="flex space-x-3 items-center">
<span className="flex items-center justify-center ">
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016zM12 9v2m0 4h.01"
/>
</svg>
</span>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<h3 className="text-xs">{check.label}</h3>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
)} )}
</div> </div>
</div> </div>
@ -94,9 +75,7 @@ export default function SelfServiceForm({ flow, config, fieldsConfig, title, but
{button} {button}
</button> </button>
<SelfServiceMessages messages={config.messages} /> {messages.length > 0 && <SelfServiceMessages messages={messages} />}
{flow && <SelfServiceMessages messages={flow.messages} />}
</form> </form>
</div> </div>
</div> </div>

View File

@ -32,8 +32,9 @@ import classnames from "classnames";
export default function SelfServiceMessages({ messages = [] }) { export default function SelfServiceMessages({ messages = [] }) {
if (!messages) return null; // make sure we don't throw on invalid data if (!messages) return null; // make sure we don't throw on invalid data
return messages.map(({ text, type }) => ( return messages.map(({ text, type }, index) => (
<p <p
key={index}
className={classnames("mt-2 text-sm", { className={classnames("mt-2 text-sm", {
"text-red-600": type === "error", "text-red-600": type === "error",
"text-blue-600": type === "info", "text-blue-600": type === "info",

View File

@ -1,9 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Head from "next/head"; import Head from "next/head";
import ky from "ky/umd";
import { useState } from "react"; import { useState } from "react";
import config from "../../src/config"; import accountsApi from "../services/accountsApi";
export default function Layout({ title, children }) { export default function Layout({ title, children }) {
const [menuOpen, openMenu] = useState(false); const [menuOpen, openMenu] = useState(false);
@ -13,9 +12,8 @@ export default function Layout({ title, children }) {
e.preventDefault(); e.preventDefault();
try { try {
await ky.post("/logout"); await accountsApi.post("logout");
router.push("/auth/login");
window.location = `${config.kratos.browser}/self-service/browser/flows/logout`;
} catch (error) { } catch (error) {
console.log(error); // todo: handle errors with a message console.log(error); // todo: handle errors with a message
} }
@ -35,23 +33,15 @@ export default function Layout({ title, children }) {
<Link href="/"> <Link href="/">
<a className="flex-shrink-0"> <a className="flex-shrink-0">
<svg <svg
viewBox="19.88800048828125 37.1175193787 132.07760620117188 132.07760620117188" role="img"
width={33} width="33"
height={33} height="33"
fill="#00C65E"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <title>Skynet</title>
d="M 116.388 139.371 C 92.969 148.816 66.759 134.5 62.048 109.691 L 46.308 98.821 C 43.843 141.32 88.308 170.55 126.346 151.435 C 130.805 149.195 134.94 146.361 138.638 143.011 L 138.698 143.011 C 141.248 140.637 140.685 136.456 137.598 134.841 L 19.888 72.671 Z" <path d="m-.0004 6.4602 21.3893 11.297c.561.2935.6633 1.0532.1999 1.4846h-.011a10.0399 10.0399 0 0 1-2.2335 1.5307c-6.912 3.4734-14.9917-1.838-14.5438-9.5605l2.8601 1.9752c.856 4.508 5.6187 7.1094 9.8742 5.3932zm8.6477 3.1509 14.3661 5.6785a.8704.8704 0 0 1 .5197 1.0466v.0182c-.1537.5377-.7668.7938-1.2575.5252zm5.2896-7.4375c2.7093-.2325 6.0946.7869 8.1116 3.3871 1.699 2.1951 2.0497 4.8772 1.9298 7.6465v-.007c-.0478.5874-.6494.9616-1.1975.745l-9.7652-3.8596 9.0656 2.4313a7.296 7.296 0 0 0-1.0677-4.5631c-2.9683-4.7678-9.9847-4.5344-12.6297.4201a7.5048 7.5048 0 0 0-.398.8831L5.5546 7.9614c.069-.1017.1417-.198.2144-.2962.1163-.2416.2417-.487.3798-.7268 1.6118-2.7911 4.3102-4.4338 7.1558-4.6973.2108-.0182.4215-.049.6323-.0672z" />
style={{ fill: "rgb(88, 181, 96)" }}
/>
<path
d="M 149.398 127.121 L 149.398 127.021 C 150.067 124.651 148.83 122.161 146.538 121.261 L 67.478 90.011 L 142.478 130.011 C 145.178 131.489 148.552 130.08 149.398 127.121 Z"
style={{ fill: "rgb(88, 181, 96)" }}
/>
<path
d="M 151.848 109.801 C 152.508 94.561 150.578 79.801 141.228 67.721 C 130.128 53.411 111.498 47.801 96.588 49.081 C 95.428 49.181 94.268 49.351 93.108 49.451 C 77.448 50.901 62.598 59.941 53.728 75.301 C 52.968 76.621 52.278 77.971 51.638 79.301 C 51.238 79.841 50.838 80.371 50.458 80.931 L 63.838 88.061 C 64.463 86.395 65.194 84.772 66.028 83.201 C 80.584 55.935 119.197 54.651 135.532 80.889 C 140.199 88.386 142.264 97.212 141.408 106.001 L 91.518 92.621 L 145.258 113.861 C 148.274 115.053 151.585 112.994 151.848 109.761 Z"
style={{ fill: "rgb(88, 181, 96)" }}
/>
</svg> </svg>
</a> </a>
</Link> </Link>
@ -152,7 +142,7 @@ export default function Layout({ title, children }) {
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="user-menu" aria-labelledby="user-menu"
> >
<Link href="/settings"> <Link href="/user/settings">
<a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem"> <a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">
Settings Settings
</a> </a>
@ -281,7 +271,7 @@ export default function Layout({ title, children }) {
</div> </div>
</div> */} </div> */}
<div className="mt-3 px-2 space-y-1"> <div className="mt-3 px-2 space-y-1">
<Link href="/settings"> <Link href="/user/settings">
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700"> <a className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700">
Settings Settings
</a> </a>

View File

@ -44,7 +44,7 @@ export default function Table({ items, count, headers, mutate, actions, offset,
if (offset < 0) setOffset(0); if (offset < 0) setOffset(0);
else if (offset >= count && count > 0) setOffset(Math.floor(count / pageSize - 1) * pageSize); else if (offset >= count && count > 0) setOffset(Math.floor(count / pageSize - 1) * pageSize);
else if (offset % pageSize) setOffset(offset - (offset % pageSize)); else if (offset % pageSize) setOffset(offset - (offset % pageSize));
}, [offset, pageSize, setOffset]); }, [count, offset, pageSize, setOffset]);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
@ -104,7 +104,7 @@ export default function Table({ items, count, headers, mutate, actions, offset,
)) ))
) : ( ) : (
<tr className="bg-white"> <tr className="bg-white">
<td colspan={headers.length + actions.length} className="text-center py-6 text-sm text-gray-500"> <td colSpan={headers.length + actions.length} className="text-center py-6 text-sm text-gray-500">
no entries no entries
</td> </td>
</tr> </tr>

View File

@ -1,13 +1,4 @@
export default { export default {
// https://github.com/ory/kratos-selfservice-ui-node#configuration
kratos: {
// The URL where ORY Kratos's Public API is located at. If this app and ORY Kratos are running in the same
// private network, this should be the private network address (e.g. kratos-public.svc.cluster.local)
public: process.env.NEXT_PUBLIC_KRATOS_PUBLIC_URL.replace(/\/+$/, ""),
// The URL where ORY Kratos's public API is located, when accessible from the public internet via ORY Oathkeeper.
// This could be for example http://kratos.my-app.com/.
browser: process.env.NEXT_PUBLIC_KRATOS_BROWSER_URL.replace(/\/+$/, ""),
},
tiers: { tiers: {
starter: { id: "starter", tier: 1, name: "Free", description: "Pin up to 100GB" }, starter: { id: "starter", tier: 1, name: "Free", description: "Pin up to 100GB" },
}, },

View File

@ -4,8 +4,13 @@ import { ToastContainer } from "react-toastify";
import Head from "next/head"; import Head from "next/head";
import "normalize.css"; import "normalize.css";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import "tailwindcss/tailwind.css"; import "../../styles/globals.css";
import "@fontsource/metropolis/all.css"; import "@fontsource/sora/300.css"; // light
import "@fontsource/sora/400.css"; // normal
import "@fontsource/sora/500.css"; // medium
import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);

View File

@ -1,21 +0,0 @@
import superagent from "superagent";
export default async (req, res) => {
if (req.cookies.ory_kratos_session) {
try {
const { header } = await superagent
.post("http://oathkeeper:4455/login")
.set("cookie", `ory_kratos_session=${req.cookies.ory_kratos_session}`);
res.setHeader("Set-Cookie", header["set-cookie"]);
res.redirect(req.query.return_to ?? "/");
} catch (error) {
console.log(`Cookie is present but authentication failed: ${error.message}`);
// credentials were correct but accounts service failed
res.redirect("/.ory/kratos/public/self-service/browser/flows/logout");
}
} else {
res.redirect("/auth/login"); // redirect to login page if kratos session is missing
}
};

View File

@ -1,4 +1,4 @@
import ky from "ky/umd"; import ky from "ky";
import Stripe from "stripe"; import Stripe from "stripe";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
@ -11,10 +11,10 @@ const getStripeCustomer = (stripeCustomerId = null) => {
return stripe.customers.create(); return stripe.customers.create();
}; };
export default async (req, res) => { export default async function billingApi(req, res) {
try { try {
const authorization = req.headers.authorization; // authorization header from request const cookie = req.headers.cookie; // cookie header from request
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { authorization } }).json(); const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { cookie } }).json();
const customer = await getStripeCustomer(stripeCustomerId); const customer = await getStripeCustomer(stripeCustomerId);
const session = await stripe.billingPortal.sessions.create({ const session = await stripe.billingPortal.sessions.create({
customer: customer.id, customer: customer.id,
@ -25,4 +25,4 @@ export default async (req, res) => {
} catch ({ message }) { } catch ({ message }) {
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } }); res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
} }
}; }

View File

@ -1,11 +1,11 @@
import ky from "ky/umd"; import ky from "ky";
import Stripe from "stripe"; import Stripe from "stripe";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { isPaidTier } from "../../../services/tiers"; import { isPaidTier } from "../../../services/tiers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const getStripeCustomer = async (user, authorization) => { const getStripeCustomer = async (user, cookie) => {
if (user.stripeCustomerId) { if (user.stripeCustomerId) {
return stripe.customers.retrieve(user.stripeCustomerId); return stripe.customers.retrieve(user.stripeCustomerId);
} }
@ -13,12 +13,12 @@ const getStripeCustomer = async (user, authorization) => {
const customer = await stripe.customers.create(); const customer = await stripe.customers.create();
// update user instance and include the customer id once created // update user instance and include the customer id once created
await ky.put(`http://accounts:3000/user`, { headers: { authorization }, json: { stripeCustomerId: customer.id } }); await ky.put("http://accounts:3000/user", { headers: { cookie }, json: { stripeCustomerId: customer.id } });
return customer; return customer;
}; };
export default async (req, res) => { export default async function checkoutApi(req, res) {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(StatusCodes.NOT_FOUND).end(); return res.status(StatusCodes.NOT_FOUND).end();
} }
@ -30,8 +30,8 @@ export default async (req, res) => {
} }
try { try {
const authorization = req.headers.authorization; // authorization header from request const cookie = req.headers.cookie; // cookie header from request
const user = await ky("http://accounts:3000/user", { headers: { authorization } }).json(); const user = await ky("http://accounts:3000/user", { headers: { cookie } }).json();
if (isPaidTier(user.tier)) { if (isPaidTier(user.tier)) {
const message = `Customer can have only one active subscription at a time, use Stripe Customer Portal to manage active subscription`; const message = `Customer can have only one active subscription at a time, use Stripe Customer Portal to manage active subscription`;
@ -39,7 +39,7 @@ export default async (req, res) => {
return res.status(StatusCodes.BAD_REQUEST).json({ error: { message } }); return res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
} }
const customer = await getStripeCustomer(user, authorization); const customer = await getStripeCustomer(user, cookie);
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode: "subscription", mode: "subscription",
payment_method_types: ["card"], payment_method_types: ["card"],
@ -55,4 +55,4 @@ export default async (req, res) => {
} catch (error) { } catch (error) {
res.status(StatusCodes.BAD_REQUEST).json({ error: { message: error.message } }); res.status(StatusCodes.BAD_REQUEST).json({ error: { message: error.message } });
} }
}; }

View File

@ -1,23 +0,0 @@
import ky from "ky/umd";
import Stripe from "stripe";
import { StatusCodes } from "http-status-codes";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async (req, res) => {
try {
const authorization = req.headers.authorization; // authorization header from request
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId, { expand: ["subscriptions"] });
const { subscriptions } = stripeCustomer;
// todo: find a better way to get current subscription
if (subscriptions.total_count) {
return res.json(subscriptions.data[0]);
}
res.status(StatusCodes.NO_CONTENT).end();
} catch ({ message }) {
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
}
};

View File

@ -1,37 +0,0 @@
export default (req, res) => {
res.json([
{
id: "price_1IReYFIzjULiPWN6DqN2DwjN",
name: "Skynet Extreme",
description: "Skynet Extreme description",
tier: 4,
price: 80,
currency: "usd",
stripe: "price_1IReYFIzjULiPWN6DqN2DwjN",
productId: "prod_J3m6IuVyh3XOc5",
livemode: false,
},
{
id: "price_1IReY5IzjULiPWN6AxPytHEG",
name: "Skynet Pro",
description: "Skynet Pro description",
tier: 3,
price: 20,
currency: "usd",
stripe: "price_1IReY5IzjULiPWN6AxPytHEG",
productId: "prod_J3m6ioQg90kZj5",
livemode: false,
},
{
id: "price_1IReXpIzjULiPWN66PvsxHL4",
name: "Skynet Plus",
description: "Skynet Plus description",
tier: 2,
price: 5,
currency: "usd",
stripe: "price_1IReXpIzjULiPWN66PvsxHL4",
productId: "prod_J3m6xMfDiz2LGE",
livemode: false,
},
]);
};

View File

@ -1,5 +0,0 @@
import user from "./user.json";
export default (req, res) => {
res.json(user);
};

View File

@ -1,12 +0,0 @@
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"sub": "ab776d6d-f324-4fa7-4k21-7587d5215481",
"tier": 1,
"subscribedUntil": "0001-01-01T00:00:00Z",
"subscriptionStatus": "active",
"subscriptionCancelAt": "2021-04-21T00:00:00Z",
"subscriptionCancelAtPeriodEnd": true,
"stripeCustomerId": "cus_J0iYnAp6LRgsTI"
}

View File

@ -1,8 +0,0 @@
import items from "./downloads.json";
export default (req, res) => {
const offset = parseInt(req.query?.offset ?? 0, 10);
const pageSize = parseInt(req.query?.pageSize ?? 10, 10);
res.json({ items: items.slice(offset, offset + pageSize), count: items.length, pageSize, offset });
};

View File

@ -1,44 +0,0 @@
[
{
"id": 1111,
"skylink": "PAL0w4SdA5rFCDGEutgpeQ50Om-YkBabtXVOJAkmedslKw",
"name": "ugabuga.pdf",
"size": 123123,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 2222,
"skylink": "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg",
"name": "ugabuga.pdf",
"size": 8912739812,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 3333,
"skylink": "IADUs8d9CQjUO34LmdaaNPK_STuZo24rpKVfYW3wPPM2uQ",
"name": "ugabuga.pdf",
"size": 123123,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 4444,
"skylink": "_A2zt5SKoqwnnZU4cBF8uBycSKULXMyeg1c5ZISBr2Q3dA",
"name": "ugabuga.pdf",
"size": 83943,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 5555,
"skylink": "AAC0uO43g64ULpyrW0zO3bjEknSFbAhm8c-RFP21EQlmSQ",
"name": "ugabuga.pdf",
"size": 3290489120,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 6666,
"skylink": "CACqf4NlIMlA0CCCieYGjpViPGyfyJ4v1x3bmuCKZX8FKA",
"name": "ugabuga.pdf",
"size": 1290389,
"downloadedOn": "2020-04-02T08:02:17-05:00"
}
]

View File

@ -1,5 +0,0 @@
import stats from "./stats.json";
export default (req, res) => {
res.json(stats);
};

View File

@ -1,13 +0,0 @@
{
"storageUsed": 809500672,
"numRegReads": 0,
"numRegWrites": 0,
"numUploads": 13,
"numDownloads": 78,
"totalUploadsSize": 618649028,
"totalDownloadsSize": 32307956843,
"bwUploads": 2810183680,
"bwDownloads": 32323934976,
"bwRegReads": 0,
"bwRegWrites": 0
}

View File

@ -1,8 +0,0 @@
import items from "./uploads.json";
export default (req, res) => {
const offset = parseInt(req.query?.offset ?? 0, 10);
const pageSize = parseInt(req.query?.pageSize ?? 10, 10);
res.json({ items: items.slice(offset, offset + pageSize), count: items.length, pageSize, offset });
};

View File

@ -1,44 +0,0 @@
[
{
"id": 1111,
"skylink": "PAL0w4SdA5rFCDGEutgpeQ50Om-YkBabtXVOJAkmedslKw",
"name": "ugabuga.pdf",
"size": 123123,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 2222,
"skylink": "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg",
"name": "ugabuga.pdf",
"size": 8912739812,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 3333,
"skylink": "IADUs8d9CQjUO34LmdaaNPK_STuZo24rpKVfYW3wPPM2uQ",
"name": "ugabuga.pdf",
"size": 123123,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 4444,
"skylink": "_A2zt5SKoqwnnZU4cBF8uBycSKULXMyeg1c5ZISBr2Q3dA",
"name": "ugabuga.pdf",
"size": 83943,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 5555,
"skylink": "AAC0uO43g64ULpyrW0zO3bjEknSFbAhm8c-RFP21EQlmSQ",
"name": "ugabuga.pdf",
"size": 3290489120,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 6666,
"skylink": "CACqf4NlIMlA0CCCieYGjpViPGyfyJ4v1x3bmuCKZX8FKA",
"name": "ugabuga.pdf",
"size": 1290389,
"uploadedOn": "2020-04-02T08:02:17-05:00"
}
]

View File

@ -1,65 +1,48 @@
import Link from "next/link"; import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client"; import { useRouter } from "next/router";
import config from "../../config"; import * as Yup from "yup";
import accountsApi from "../../services/accountsApi";
import useAnonRoute from "../../services/useAnonRoute";
import SelfServiceForm from "../../components/Form/SelfServiceForm"; import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public })); const fieldsConfig = [
{
export async function getServerSideProps(context) { name: "email",
const flow = context.query.flow; type: "text",
const redirect = encodeURIComponent(`/api/accounts/login?return_to=${context.query.return_to ?? "/"}`);
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../../stubs/login.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing login flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/login/browser?return_to=${redirect}`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceLoginFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
console.log(`Unexpected error retrieving login flow: ${error.message}`);
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/login/browser?return_to=${redirect}`,
},
};
}
}
const fieldsConfig = {
identifier: {
label: "Email address", label: "Email address",
autoComplete: "email", autoComplete: "email",
position: 0, position: 0,
}, },
password: { {
name: "password",
type: "password",
label: "Password", label: "Password",
autoComplete: "current-password", autoComplete: "current-password",
position: 1, position: 1,
}, },
csrf_token: { ];
position: 99,
}, const validationSchema = Yup.object().shape({
}; email: Yup.string().required("Email is required").email("This email is invalid"),
password: Yup.string().required("Password is required"),
});
export default function Login() {
useAnonRoute(); // ensure user is not logged in
const router = useRouter();
const onSubmit = async (values) => {
await accountsApi.post("login", {
json: {
email: values.email,
password: values.password,
},
});
router.push("/");
};
export default function Login({ flow }) {
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
@ -82,14 +65,19 @@ export default function Login({ flow }) {
<Link href="/auth/registration"> <Link href="/auth/registration">
<a className="font-medium text-green-600 hover:text-green-500">sign up</a> <a className="font-medium text-green-600 hover:text-green-500">sign up</a>
</Link>{" "} </Link>{" "}
if you don't have one yet if you don&apos;t have one yet
</p> </p>
</div> </div>
<SelfServiceForm flow={flow} config={flow.methods.password.config} fieldsConfig={fieldsConfig} button="Sign in" /> <SelfServiceForm
fieldsConfig={fieldsConfig}
validationSchema={validationSchema}
onSubmit={onSubmit}
button="Sign in"
/>
<div className="sm:mx-auto sm:w-full sm:max-w-md text-center mt-2"> <div className="sm:mx-auto sm:w-full sm:max-w-md text-center mt-2">
<Link href="/recovery"> <Link href="/auth/recovery">
<a className="text-sm font-medium text-green-600 hover:text-green-500">Forgot your password?</a> <a className="text-sm font-medium text-green-600 hover:text-green-500">Forgot your password?</a>
</Link> </Link>
</div> </div>

View File

@ -1,57 +1,39 @@
import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client"; import * as Yup from "yup";
import config from "../config"; import accountsApi from "../../services/accountsApi";
import SelfServiceForm from "../components/Form/SelfServiceForm"; import useAnonRoute from "../../services/useAnonRoute";
import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public })); const fieldsConfig = [
{
export async function getServerSideProps(context) { name: "email",
const flow = context.query.flow; type: "text",
label: "Email address",
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../stubs/recovery.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing recovery flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/recovery/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceRecoveryFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/recovery/browser`,
},
};
}
}
const fieldsConfig = {
email: {
label: "Your email",
autoComplete: "email", autoComplete: "email",
position: 0, position: 0,
}, },
csrf_token: { ];
position: 99,
}, const validationSchema = Yup.object().shape({
}; email: Yup.string().required("Email is required").email("This email is invalid"),
});
export default function Recovery() {
useAnonRoute(); // ensure user is not logged in
const [success, setSuccess] = React.useState(false);
const onSubmit = async (values) => {
await accountsApi.post("user/recover/request", {
json: {
email: values.email,
},
});
setSuccess(true);
};
export default function Recovery({ flow }) {
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
@ -84,12 +66,20 @@ export default function Recovery({ flow }) {
</p> </p>
</div> </div>
<SelfServiceForm {!success && (
flow={flow} <SelfServiceForm
config={flow.methods.link.config} fieldsConfig={fieldsConfig}
fieldsConfig={fieldsConfig} validationSchema={validationSchema}
button="Send recovery link" onSubmit={onSubmit}
/> button="Send recovery link"
/>
)}
{success && (
<p className="mt-4 text-center text-primary">
Account recovery requested, please follow instructions sent in email.
</p>
)}
</div> </div>
); );
} }

View File

@ -1,88 +1,66 @@
import Link from "next/link"; import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client"; import { useRouter } from "next/router";
import { getIn } from "formik";
import config from "../../config";
import levenshtein from "fast-levenshtein"; import levenshtein from "fast-levenshtein";
import * as Yup from "yup";
import accountsApi from "../../services/accountsApi";
import useAnonRoute from "../../services/useAnonRoute";
import lcs from "../../services/longestCommonSequence"; import lcs from "../../services/longestCommonSequence";
import SelfServiceForm from "../../components/Form/SelfServiceForm"; import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public })); const fieldsConfig = [
{
export async function getServerSideProps(context) { name: "email",
const flow = context.query.flow; type: "text",
const redirect = encodeURIComponent(`/api/accounts/login?return_to=${context.query.return_to ?? "/"}`);
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../../stubs/registration.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing registration flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/registration/browser?return_to=${redirect}`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceRegistrationFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
console.log(`Unexpected error retrieving registration flow: ${error.message}`);
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/registration/browser?return_to=${redirect}`,
},
};
}
}
const fieldsConfig = {
"traits.email": {
label: "Email address", label: "Email address",
autoComplete: "email", autoComplete: "email",
position: 0, position: 0,
}, },
password: { {
name: "password",
type: "password",
label: "Password", label: "Password",
autoComplete: "new-password", autoComplete: "new-password",
position: 1, position: 1,
checks: [
{
label: "At least 6 characters long",
validate: (values, field) => {
const value = getIn(values, field);
return value && value.length > 5;
},
},
{
label: "Significantly different from the email",
validate: (values, field) => {
const value = getIn(values, field);
const email = getIn(values, "traits.email");
// levenshtein distance higher than 5 and longest common sequence shorter than half of the password
return value && email && levenshtein.get(value, email) > 5 && lcs(value, email).length / value.length <= 0.5;
},
},
],
}, },
csrf_token: { {
position: 99, name: "confirmPassword",
type: "password",
label: "Password repeated",
autoComplete: "new-password",
position: 2,
}, },
}; ];
const validationSchema = Yup.object().shape({
email: Yup.string().required("Email is required").email("This email is invalid"),
password: Yup.string()
.required("Password is required")
.min(6, "Password has to be at least 6 characters long")
.test("levenshtein", "This password is too similar to your email", function (value) {
const email = this.parent.email;
// levenshtein distance higher than 5 and longest common sequence shorter than half of the password
return value && email && levenshtein.get(value, email) > 5 && lcs(value, email).length / value.length <= 0.5;
}),
confirmPassword: Yup.string().oneOf([Yup.ref("password"), null], "Passwords must match"),
});
export default function Registration() {
useAnonRoute(); // ensure user is not logged in
const router = useRouter();
const onSubmit = async (values) => {
await accountsApi.post("user", {
json: {
email: values.email,
password: values.password,
},
});
router.push("/");
};
export default function Registration({ flow }) {
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
@ -109,7 +87,12 @@ export default function Registration({ flow }) {
</p> </p>
</div> </div>
<SelfServiceForm flow={flow} config={flow.methods.password.config} fieldsConfig={fieldsConfig} button="Sign up" /> <SelfServiceForm
fieldsConfig={fieldsConfig}
validationSchema={validationSchema}
onSubmit={onSubmit}
button="Sign up"
/>
</div> </div>
); );
} }

View File

@ -3,12 +3,10 @@ import prettyBytes from "pretty-bytes";
import { useState } from "react"; import { useState } from "react";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import Table from "../components/Table"; import Table from "../components/Table";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js"; import { SkynetClient } from "skynet-js";
import useAccountsApi from "../services/useAccountsApi"; import useAccountsApi from "../services/useAccountsApi";
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API); const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink); const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink);
const getRelativeDate = ({ downloadedOn }) => dayjs(downloadedOn).format("YYYY-MM-DD HH:mm:ss"); const getRelativeDate = ({ downloadedOn }) => dayjs(downloadedOn).format("YYYY-MM-DD HH:mm:ss");
const headers = [ const headers = [
@ -36,22 +34,15 @@ const headers = [
]; ];
const actions = []; const actions = [];
export const getServerSideProps = authServerSideProps(async (context, api) => { export default function Downloads() {
const initialData = await api.get("user/downloads?pageSize=10&offset=0").json();
return { props: { initialData } };
});
export default function Downloads({ initialData }) {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const { data } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${offset}`, { const { data } = useAccountsApi(`user/downloads?pageSize=10&offset=${offset}`, {
initialData: offset === 0 ? initialData : undefined,
revalidateOnMount: true, revalidateOnMount: true,
}); });
// preload next page if it exists (based on the response from the current page query) // preload next page if it exists (based on the response from the current page query)
const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset; const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset;
useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${nextPageOffset}`); useAccountsApi(`user/downloads?pageSize=10&offset=${nextPageOffset}`);
return ( return (
<Layout title="Your downloads"> <Layout title="Your downloads">

View File

@ -4,23 +4,14 @@ import relativeTime from "dayjs/plugin/relativeTime";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import Link from "next/link"; import Link from "next/link";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js"; import { SkynetClient } from "skynet-js";
import config from "../config"; import config from "../config";
import useAccountsApi from "../services/useAccountsApi"; import useAccountsApi from "../services/useAccountsApi";
import { isFreeTier } from "../services/tiers"; import React from "react";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API); const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
export const getServerSideProps = authServerSideProps(async (context, api) => {
const stripe = await api.get("stripe/prices").json();
const plans = [config.tiers.starter, ...stripe].sort((a, b) => a.tier - b.tier);
return { props: { plans } };
});
function SkylinkList({ items = [], timestamp }) { function SkylinkList({ items = [], timestamp }) {
return ( return (
@ -99,13 +90,19 @@ function SkylinkList({ items = [], timestamp }) {
); );
} }
export default function Home({ plans }) { export default function Home() {
const { data: user } = useAccountsApi(`${apiPrefix}/user`); const { data: prices } = useAccountsApi("stripe/prices");
const { data: stats } = useAccountsApi(`${apiPrefix}/user/stats`); const { data: user } = useAccountsApi("user");
const { data: downloads } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=3&offset=0`); const { data: stats } = useAccountsApi("user/stats");
const { data: uploads } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=3&offset=0`); const { data: downloads } = useAccountsApi("user/downloads?pageSize=3&offset=0");
const { data: uploads } = useAccountsApi("user/uploads?pageSize=3&offset=0");
const [plans, setPlans] = React.useState([config.tiers.starter]);
const activePlan = plans.find(({ tier }) => (user ? user.tier === tier : isFreeTier(tier))); React.useEffect(() => {
if (prices) setPlans((plans) => [...plans, ...prices].sort((a, b) => a.tier - b.tier));
}, [setPlans, prices]);
const activePlan = plans.find(({ tier }) => user && user.tier === tier);
return ( return (
<Layout title="Dashboard"> <Layout title="Dashboard">
@ -135,7 +132,7 @@ export default function Home({ plans }) {
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt> <dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt>
<dd className="flex items-baseline"> <dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{activePlan.name}</div> <div className="text-2xl font-semibold text-gray-900">{activePlan?.name}</div>
</dd> </dd>
</div> </div>
</div> </div>

View File

@ -1,16 +1,13 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import ky from "ky/umd"; import ky from "ky";
import { useEffect, useState } from "react"; import * as React from "react";
import authServerSideProps from "../services/authServerSideProps";
import classnames from "classnames"; import classnames from "classnames";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import config from "../config"; import config from "../config";
import useAccountsApi from "../services/useAccountsApi"; import useAccountsApi from "../services/useAccountsApi";
import { isFreeTier, isPaidTier } from "../services/tiers"; import { isFreeTier, isPaidTier } from "../services/tiers";
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const ActiveBadge = () => { const ActiveBadge = () => {
return ( return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-green-200 text-green-800 ml-3"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-green-200 text-green-800 ml-3">
@ -19,22 +16,23 @@ const ActiveBadge = () => {
); );
}; };
export const getServerSideProps = authServerSideProps(async (context, api) => { export default function Payments() {
const [user, stats, stripe] = await Promise.all([ const { data: user } = useAccountsApi("user");
api.get("user").json(), const { data: stats } = useAccountsApi("user/stats");
api.get("user/stats").json(), const { data: prices } = useAccountsApi("stripe/prices");
api.get("stripe/prices").json(), const [plans, setPlans] = React.useState([config.tiers.starter]);
]); const [selectedPlan, setSelectedPlan] = React.useState(null);
const plans = [config.tiers.starter, ...stripe].sort((a, b) => a.tier - b.tier);
return { props: { plans, user, stats } }; React.useEffect(() => {
}); if (prices) setPlans((plans) => [...plans, ...prices].sort((a, b) => a.tier - b.tier));
}, [setPlans, prices]);
const activePlan = plans.find(({ tier }) => user && user.tier === tier);
React.useEffect(() => {
setSelectedPlan(activePlan);
}, [activePlan, setSelectedPlan]);
export default function Payments({ plans, user: initialUserData, stats: initialStatsData }) {
const { data: user } = useAccountsApi(`${apiPrefix}/user`, { initialData: initialUserData });
const { data: stats } = useAccountsApi(`${apiPrefix}/user/stats`, { initialData: initialStatsData });
const [selectedPlan, setSelectedPlan] = useState(plans.find(({ tier }) => isFreeTier(tier)));
const activePlan = plans.find(({ tier }) => (user ? user.tier === tier : isFreeTier(tier)));
const handleSubscribe = async () => { const handleSubscribe = async () => {
try { try {
const price = selectedPlan.stripe; const price = selectedPlan.stripe;
@ -46,12 +44,6 @@ export default function Payments({ plans, user: initialUserData, stats: initialS
} }
}; };
useEffect(() => {
if (activePlan && isPaidTier(activePlan.tier)) {
setSelectedPlan(activePlan);
}
}, [activePlan, selectedPlan, setSelectedPlan]);
return ( return (
<Layout title="Payments"> <Layout title="Payments">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6"> <div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">

View File

@ -41,7 +41,7 @@ export default function Payments() {
</a> </a>
</div> </div>
<div className="pt-6 pb-8 px-6"> <div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3> <h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What&apos;s included</h3>
<ul className="mt-6 space-y-4"> <ul className="mt-6 space-y-4">
<li className="flex space-x-3"> <li className="flex space-x-3">
{/* Heroicon name: solid/check */} {/* Heroicon name: solid/check */}
@ -96,7 +96,7 @@ export default function Payments() {
</a> </a>
</div> </div>
<div className="pt-6 pb-8 px-6"> <div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3> <h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What&apos;s included</h3>
<ul className="mt-6 space-y-4"> <ul className="mt-6 space-y-4">
<li className="flex space-x-3"> <li className="flex space-x-3">
{/* Heroicon name: solid/check */} {/* Heroicon name: solid/check */}
@ -168,7 +168,7 @@ export default function Payments() {
</a> </a>
</div> </div>
<div className="pt-6 pb-8 px-6"> <div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3> <h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What&apos;s included</h3>
<ul className="mt-6 space-y-4"> <ul className="mt-6 space-y-4">
<li className="flex space-x-3"> <li className="flex space-x-3">
{/* Heroicon name: solid/check */} {/* Heroicon name: solid/check */}
@ -257,7 +257,7 @@ export default function Payments() {
</a> </a>
</div> </div>
<div className="pt-6 pb-8 px-6"> <div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3> <h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What&apos;s included</h3>
<ul className="mt-6 space-y-4"> <ul className="mt-6 space-y-4">
<li className="flex space-x-3"> <li className="flex space-x-3">
{/* Heroicon name: solid/check */} {/* Heroicon name: solid/check */}

View File

@ -1,79 +0,0 @@
import { Configuration, PublicApi } from "@ory/kratos-client";
import Layout from "../components/Layout";
import config from "../config";
import SelfServiceForm from "../components/Form/SelfServiceForm";
import authServerSideProps from "../services/authServerSideProps";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export const getServerSideProps = authServerSideProps(async (context) => {
const flow = context.query.flow;
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../stubs/settings.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing settings flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/settings/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceSettingsFlow(flow, {
headers: { cookie: context.req.headers.cookie },
});
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/settings/browser`,
},
};
}
});
const fieldsConfig = {
"traits.email": {
label: "Email address",
autoComplete: "email",
position: 0,
},
password: {
label: "Password",
autoComplete: "new-password",
position: 1,
},
csrf_token: {
position: 99,
},
};
export default function Settings({ flow }) {
const profileConfig = flow.methods.profile.config;
const passwordConfig = flow.methods.password.config;
return (
<Layout title="Settings">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
<SelfServiceForm config={profileConfig} fieldsConfig={fieldsConfig} title="Account settings" button="Update" />
<SelfServiceForm
config={passwordConfig}
fieldsConfig={fieldsConfig}
title="Authentication settings"
button="Update"
/>
</div>
</Layout>
);
}

View File

@ -1,16 +1,14 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { useState } from "react"; import { useState } from "react";
import ky from "ky/umd"; import ky from "ky";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import Table from "../components/Table"; import Table from "../components/Table";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js"; import { SkynetClient } from "skynet-js";
import useAccountsApi from "../services/useAccountsApi"; import useAccountsApi from "../services/useAccountsApi";
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API); const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink); const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink);
const getRelativeDate = ({ uploadedOn }) => dayjs(uploadedOn).format("YYYY-MM-DD HH:mm:ss"); const getRelativeDate = ({ uploadedOn }) => dayjs(uploadedOn).format("YYYY-MM-DD HH:mm:ss");
const headers = [ const headers = [
@ -40,7 +38,7 @@ const actions = [
{ {
name: "Unpin Skylink", name: "Unpin Skylink",
action: async ({ skylink }, mutate) => { action: async ({ skylink }, mutate) => {
await toast.promise(ky.delete(`/user/uploads/${skylink}`), { await toast.promise(ky.delete(`/api/user/uploads/${skylink}`), {
pending: "Unpinning Skylink", pending: "Unpinning Skylink",
success: "Skylink unpinned", success: "Skylink unpinned",
error: (error) => error.message, error: (error) => error.message,
@ -51,22 +49,15 @@ const actions = [
}, },
]; ];
export const getServerSideProps = authServerSideProps(async (context, api) => { export default function Uploads() {
const initialData = await api.get("user/uploads?pageSize=10&offset=0").json();
return { props: { initialData } };
});
export default function Uploads({ initialData }) {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const { data, mutate } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${offset}`, { const { data, mutate } = useAccountsApi(`user/uploads?pageSize=10&offset=${offset}`, {
initialData: offset === 0 ? initialData : undefined,
revalidateOnMount: true, revalidateOnMount: true,
}); });
// preload next page if it exists (based on the response from the current page query) // preload next page if it exists (based on the response from the current page query)
const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset; const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset;
useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${nextPageOffset}`); useAccountsApi(`user/uploads?pageSize=10&offset=${nextPageOffset}`);
return ( return (
<Layout title="Your uploads"> <Layout title="Your uploads">

View File

@ -0,0 +1,57 @@
import Link from "next/link";
import { useRouter } from "next/router";
import accountsApi from "../../services/accountsApi";
import useAnonRoute from "../../services/useAnonRoute";
import React from "react";
export default function Recover() {
useAnonRoute(); // ensure user is not logged in
const router = useRouter();
const [error, setError] = React.useState("");
const [confirmed, setConfirmed] = React.useState(false);
React.useEffect(() => {
async function confirm() {
try {
await accountsApi.get("user/confirm", { searchParams: { token: router.query.token } });
setConfirmed(true);
setTimeout(() => {
router.push("/");
}, 5000);
} catch (error) {
if (error.response) {
const data = await error.response.json();
setError(data.message ?? error.toString());
} else {
setError(error.toString());
}
}
}
if (router.query.token) confirm();
}, [router, setConfirmed]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
{!confirmed && !error && (
<h2 className="mt-6 text-center text-xl text-gray-900">Confirming your email, please wait.</h2>
)}
{confirmed && (
<h2 className="mt-6 text-center text-xl">
Email confirmed! You will be redirected to the{" "}
<Link href="/">
<a className="text-primary hover:text-primary-light">dashboard</a>
</Link>{" "}
in 3 seconds.
</h2>
)}
{error && <h2 className="mt-6 text-center text-xl text-red-900">{error}</h2>}
</div>
</div>
);
}

View File

@ -1,31 +1,53 @@
import Link from "next/link"; import * as Yup from "yup";
import { Configuration, PublicApi } from "@ory/kratos-client"; import { useRouter } from "next/router";
import config from "../config"; import accountsApi from "../../services/accountsApi";
import useAnonRoute from "../../services/useAnonRoute";
import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public })); const fieldsConfig = [
{
name: "password",
type: "password",
label: "Password",
autoComplete: "new-password",
position: 0,
},
{
name: "confirmPassword",
type: "password",
label: "Password repeated",
autoComplete: "new-password",
position: 1,
},
{
name: "token",
type: "hidden",
position: 2,
},
];
export async function getServerSideProps(context) { const validationSchema = Yup.object().shape({
const error = context.query.error; password: Yup.string().required("Password is required").min(6, "Password has to be at least 6 characters long"),
confirmPassword: Yup.string().oneOf([Yup.ref("password"), null], "Passwords must match"),
});
// No error was send, redirecting back to home. export default function Recover() {
if (!error || typeof error !== "string") { useAnonRoute(); // ensure user is not logged in
console.log("No error ID found in URL, redirecting to homepage.");
return { redirect: { permanent: false, destination: "/" } }; const router = useRouter();
}
try { const onSubmit = async (values) => {
const { status, data } = await kratos.getSelfServiceError(error); await accountsApi.post("user/recover", {
json: {
token: router.query.token,
password: values.password,
confirmPassword: values.confirmPassword,
},
});
if ("errors" in data) return { props: { errors: data.errors } }; router.push("/");
};
throw new Error(`Expected error ${error} to contain "errors" but got ${JSON.stringify(data)}`);
} catch (error) {
return { redirect: { permanent: false, destination: "/" } };
}
}
export default function Error({ errors }) {
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
@ -42,27 +64,15 @@ export default function Error({ errors }) {
fillRule="evenodd" fillRule="evenodd"
/> />
</svg> </svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">An error occurred</h2> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Set new password</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{errors.map((error, index) => (
<div className={`${index > 1 ? "mt-3 sm:mt-5" : ""} text-center`}>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
{error.code} - {error.message}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{error.reason}</p>
</div>
</div>
))}
</div>
</div>
<div className="text-center mt-8">
<Link href="/">
<a className="font-medium text-green-600 hover:text-green-500">back to homepage</a>
</Link>
</div> </div>
<SelfServiceForm
fieldsConfig={fieldsConfig}
validationSchema={validationSchema}
onSubmit={onSubmit}
button="Confirm"
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,85 @@
import * as Yup from "yup";
import Layout from "../../components/Layout";
import SelfServiceForm from "../../components/Form/SelfServiceForm";
import accountsApi from "../../services/accountsApi";
const profileConfig = [
{
name: "email",
type: "text",
label: "New email address",
autoComplete: "email",
position: 0,
},
{
name: "confirmEmail",
type: "text",
label: "New email address repeated",
autoComplete: "email",
position: 1,
},
];
const passwordConfig = [
{
name: "password",
type: "password",
label: "New password",
position: 1,
},
{
name: "confirmPassword",
type: "password",
label: "New password repeated",
position: 2,
},
];
const emailValidationSchema = Yup.object().shape({
email: Yup.string().required("Email is required").email("This email is invalid"),
confirmEmail: Yup.string().oneOf([Yup.ref("email"), null], "Emails must match"),
});
const passwordValidationSchema = Yup.object().shape({
password: Yup.string().required("Password is required").min(6, "Password has to be at least 6 characters long"),
confirmPassword: Yup.string().oneOf([Yup.ref("password"), null], "Passwords must match"),
});
export default function Settings() {
const onEmailSubmit = async (values) => {
await accountsApi.put("user", {
json: {
email: values.email,
},
});
};
const onPasswordSubmit = async (values) => {
await accountsApi.put("user", {
json: {
password: values.password,
},
});
};
return (
<Layout title="Settings">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
<SelfServiceForm
fieldsConfig={profileConfig}
validationSchema={emailValidationSchema}
onSubmit={onEmailSubmit}
title="Account settings"
button="Update"
/>
<SelfServiceForm
fieldsConfig={passwordConfig}
validationSchema={passwordValidationSchema}
onSubmit={onPasswordSubmit}
title="Authentication settings"
button="Update"
/>
</div>
</Layout>
);
}

View File

@ -1,103 +0,0 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import config from "../config";
import SelfServiceForm from "../components/Form/SelfServiceForm";
import { useEffect } from "react";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const flow = context.query.flow;
// if (process.env.NODE_ENV === "development") {
// return { props: { flow: require("../../stubs/recovery.json") } };
// }
if (!flow || typeof flow !== "string") {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/verification/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceVerificationFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/verification/browser`,
},
};
}
}
const fieldsConfig = {
email: {
label: "Your email",
autoComplete: "email",
position: 0,
},
csrf_token: {
position: 99,
},
};
export default function Verify({ flow }) {
const state = flow.state;
useEffect(() => {
if (state === "passed_challenge") {
setTimeout(() => (window.location = "/"), 5000);
}
}, [state]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow.state === "passed_challenge" ? "Verification successful!" : "Account verification"}
</h2>
{flow.state === "passed_challenge" && (
<>
<p className="mt-2 text-center text-sm text-gray-600 max-w">You will be redirected automatically</p>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
<Link href="/">
<a className="font-medium text-green-600 hover:text-green-500">go to dashboard</a>
</Link>
</p>
</>
)}
</div>
{flow.state !== "passed_challenge" && (
<SelfServiceForm
flow={flow}
config={flow.methods.link.config}
fieldsConfig={fieldsConfig}
button="Resend verification link"
/>
)}
</div>
);
}

View File

@ -0,0 +1,5 @@
import ky from "ky";
const prefix = process.env.NEXT_PUBLIC_SKYNET_DASHBOARD_URL ?? "";
export default ky.create({ prefixUrl: `${prefix}/api` });

View File

@ -1,37 +0,0 @@
import ky from "ky/umd";
const isProduction = process.env.NODE_ENV === "production";
export default function authServerSideProps(getServerSideProps) {
return function authenticate(context) {
const authCookies = ["ory_kratos_session", "skynet-jwt"];
if (isProduction && !authCookies.every((cookie) => context.req.cookies[cookie])) {
// it is higly unusual that some of the cookies would be set but other would not
if (authCookies.some((cookie) => context.req.cookies[cookie])) {
console.log(
"Unexpected auth cookies state!",
authCookies.map((cookie) => `[${cookie}] is ${context.req.cookies[cookie] ? "set" : "not set"}`)
);
}
return {
redirect: {
permanent: false,
destination: `/api/accounts/login?return_to=${encodeURIComponent(context.resolvedUrl)}`,
},
};
}
if (getServerSideProps) {
const api = ky.create({
headers: { cookie: context.req.headers.cookie },
prefixUrl: isProduction ? "http://oathkeeper:4455" : "http://localhost:3000/api/stubs",
});
return getServerSideProps(context, api);
}
return { props: {} };
};
}

View File

@ -1,15 +1,21 @@
import useSWR from "swr"; import useSWR from "swr";
import { useRouter } from "next/router";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
const fetcher = (url) => const prefix = process.env.NEXT_PUBLIC_SKYNET_DASHBOARD_URL ?? "";
fetch(url).then((res) => {
const fetcher = (url, router) => {
return fetch(url).then((res) => {
if (res.status === StatusCodes.UNAUTHORIZED) { if (res.status === StatusCodes.UNAUTHORIZED) {
window.location.href = `/auth/login?return_to=${encodeURIComponent(window.location.href)}`; router.push(`/auth/login?return_to=${encodeURIComponent(window.location.href)}`);
} }
return res.json(); return res.json();
}); });
};
export default function useAccountsApi(key, config) { export default function useAccountsApi(key, config) {
return useSWR(key, fetcher, config); const router = useRouter();
return useSWR(`${prefix}/api/${key}`, (url) => fetcher(url, router), config);
} }

View File

@ -0,0 +1,16 @@
import useSWR from "swr";
import { useRouter } from "next/router";
import { StatusCodes } from "http-status-codes";
const prefix = process.env.NEXT_PUBLIC_SKYNET_DASHBOARD_URL ?? "";
const fetcher = (url, router) => {
return fetch(url).then((res) => {
if (res.status === StatusCodes.OK) router.push("/");
});
};
export default function useAnonRoute() {
const router = useRouter();
return useSWR(`${prefix}/api/user`, (url) => fetcher(url, router));
}

View File

@ -1,45 +0,0 @@
{
"id": "bda73c77-1e21-4bfd-b85a-322fce2e4576",
"expires_at": "2020-01-28T13:48:04.690715Z",
"issued_at": "2020-01-28T13:38:04.690732Z",
"request_url": "http://127.0.0.1:4455/auth/browser/login",
"methods": {
"oidc": {
"method": "oidc",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/oidc/auth/bda73c77-1e21-4bfd-b85a-322fce2e4576",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "QJreyXtUD4oUSJfGNjA/+6ydsQyq0o/rfTL6QK86VadVFg6mwgX5x1QHVQ6uRqKxmwAcavQup3ILCSwl7ke97g=="
}
]
}
},
"password": {
"method": "password",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/password/login?request=bda73c77-1e21-4bfd-b85a-322fce2e4576",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "M1gAKA8fIhw4JOpQ/5m9mKARBAvKhzWkyhbxjtZNLG8m1NBHtk7UUXhrKJhn7yDSl4ypbZR7HT28LSfrlzDEJg=="
},
{ "name": "identifier", "type": "text", "required": true, "value": "asfdasdffads" },
{ "name": "password", "type": "password", "required": true }
],
"errors": [
{
"message": "The provided credentials are invalid. Check for spelling mistakes in your password or username, email address, or phone number."
}
]
}
}
}
}

View File

@ -1,31 +0,0 @@
{
"id": "01e21a99-8e69-41f9-b3ba-4967f714c8eb",
"type": "browser",
"expires_at": "2021-03-18T15:46:30.634635Z",
"issued_at": "2021-03-18T14:46:30.634635Z",
"request_url": "http://oathkeeper:4455/self-service/recovery/browser",
"messages": null,
"methods": {
"link": {
"method": "link",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/self-service/recovery/methods/link?flow=01e21a99-8e69-41f9-b3ba-4967f714c8eb",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "XvPfLJzxmcTWnr2rmLzFcED9Ef1PIkqSBQgx5t0yu0WuIp2S6ll+B9cQ0ClIcLQ=="
},
{
"name": "email",
"type": "email",
"required": true
}
]
}
}
},
"state": "choose_method"
}

View File

@ -1,58 +0,0 @@
{
"id": "dbff7b96-8116-42c5-8624-f9fb28f1db15",
"expires_at": "2020-01-28T13:49:01.2274112Z",
"issued_at": "2020-01-28T13:39:01.2274261Z",
"request_url": "http://127.0.0.1:4455/auth/browser/registration",
"methods": {
"oidc": {
"method": "oidc",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/oidc/auth/dbff7b96-8116-42c5-8624-f9fb28f1db15",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "xwb6A6iHdsguYwkAM6m3jj196E7TcmiWpAavIRxuAgXSiipsEdaAhW4sy8ir3yrECuBFKI2OQA/SPXlEXRPqTA=="
}
]
}
},
"password": {
"method": "password",
"config": {
"errors": [
{
"message": "The provided credentials are invalid. Check for spelling mistakes in your password or username, email address, or phone number."
}
],
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/password/registration?request=dbff7b96-8116-42c5-8624-f9fb28f1db15",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "xLg4B9WnuC0Ue+j9ay5EQvleaJpOl0H9xJJ7W3+Bwv7RNOhobPZOYFQ0KjXzWNkIzsPF/BBraWSyqa0+Pvwqtw=="
},
{
"name": "password",
"type": "password",
"required": true,
"errors": [{ "message": "password: Is required" }]
},
{
"name": "traits.email",
"type": "text",
"value": "",
"errors": [
{ "message": "traits.email: String length must be greater than or equal to 3" },
{ "message": "traits.email: Does not match format 'email'" }
]
}
]
}
}
}
}

View File

@ -1,49 +0,0 @@
{
"id": "e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"type": "browser",
"expires_at": "2021-02-18T13:31:43.947193Z",
"issued_at": "2021-02-18T12:31:43.947193Z",
"request_url": "http://oathkeeper:4455/self-service/settings/browser",
"messages": null,
"methods": {
"password": {
"method": "password",
"config": {
"action": "https://account.siasky.xyz/.ory/kratos/public/self-service/settings/methods/password?flow=e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"method": "POST",
"fields": [
{ "name": "password", "type": "password", "required": true },
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "i33iydUBN6jqwrkHlLPZ3ap7Ah4uC0UrTadn7zKT0jyouDpr+DF0/1Hbkshye9t7IKwyzOcLt7i6oR/OoEVg+g=="
}
]
}
},
"profile": {
"method": "profile",
"config": {
"action": "https://account.siasky.xyz/.ory/kratos/public/self-service/settings/methods/profile?flow=e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "i33iydUBN6jqwrkHlLPZ3ap7Ah4uC0UrTadn7zKT0jyouDpr+DF0/1Hbkshye9t7IKwyzOcLt7i6oR/OoEVg+g=="
},
{ "name": "traits.email", "type": "email", "value": "karol@nebulous.tech" }
]
}
}
},
"identity": {
"id": "ab776d6d-f324-4fa7-a728-7587d5215481",
"schema_id": "default",
"schema_url": "",
"traits": { "email": "karol@nebulous.tech" }
},
"state": "show_form"
}

View File

@ -1,122 +0,0 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
.footer img {
margin-left: 0.5rem;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New,
monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

View File

@ -1,16 +1,3 @@
html, @tailwind base;
body { @tailwind components;
padding: 0; @tailwind utilities;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans,
Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}

View File

@ -1,18 +1,65 @@
const defaultTheme = require("tailwindcss/defaultTheme");
const plugin = require("tailwindcss/plugin");
const colors = {
primary: { light: "#33D17E", DEFAULT: "#00c65e" },
warning: "#ffd567",
error: "#ED5454",
palette: {
100: "#f5f7f7",
200: "#d4dddb",
300: "#9e9e9e",
400: "#555555",
500: "#242424",
600: "#0d0d0d",
},
};
module.exports = { module.exports = {
purge: ["./src/**/*.js"], purge: ["./src/**/*.{js,jsx,ts,tsx}"],
darkMode: false, // or 'media' or 'class' darkMode: false, // or 'media' or 'class'
theme: { theme: {
screens: {
sm: "640px",
tablet: "640px",
md: "768px",
lg: "1024px",
desktop: "1024px",
xl: "1280px",
hires: "1408px",
"2xl": "1536px",
},
backgroundColor: (theme) => ({ ...theme("colors"), ...colors }),
borderColor: (theme) => ({ ...theme("colors"), ...colors }),
textColor: (theme) => ({ ...theme("colors"), ...colors }),
placeholderColor: (theme) => ({ ...theme("colors"), ...colors }),
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"], sans: ["Sora", ...defaultTheme.fontFamily.sans],
content: ["Source\\ Sans\\ Pro", ...defaultTheme.fontFamily.sans],
}, },
},
},
variants: {
extend: {
backgroundColor: ["disabled"], backgroundColor: ["disabled"],
textColor: ["disabled"], textColor: ["disabled"],
}, },
}, },
plugins: [require("@tailwindcss/forms")], variants: {
extend: {
animation: ["hover"],
rotate: ["hover"],
backgroundColor: ["disabled"],
textColor: ["disabled"],
margin: ["first"],
},
},
plugins: [
require("@tailwindcss/typography"),
require("@tailwindcss/forms"),
plugin(function ({ addBase, theme }) {
addBase({
body: {
color: theme("textColor.palette.600"),
},
});
}),
],
}; };

File diff suppressed because it is too large Load Diff