Merge branch 'master' into portal-latest
This commit is contained in:
commit
79f39624eb
|
@ -53,7 +53,6 @@ typings/
|
|||
|
||||
# dotenv environment variable files
|
||||
.env*
|
||||
./docker/kratos/config/kratos.yml
|
||||
|
||||
# Mac files
|
||||
.DS_Store
|
||||
|
@ -95,8 +94,8 @@ docker/kratos/cr_certs/*.key
|
|||
|
||||
# Oathkeeper JWKS signing token
|
||||
docker/kratos/oathkeeper/id_token.jwks.json
|
||||
/docker/kratos/config/kratos.yml
|
||||
docker/kratos/config/kratos.yml
|
||||
|
||||
# Setup-script log files
|
||||
/setup-scripts/serverload.log
|
||||
/setup-scripts/serverload.json
|
||||
setup-scripts/serverload.log
|
||||
setup-scripts/serverload.json
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
version: "3.7"
|
||||
|
||||
x-logging: &default-logging
|
||||
x-logging:
|
||||
&default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
|
@ -29,20 +30,23 @@ services:
|
|||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- SKYNET_DB_HOST=${SKYNET_DB_HOST}
|
||||
- SKYNET_DB_PORT=${SKYNET_DB_PORT}
|
||||
- SKYNET_DB_USER=${SKYNET_DB_USER}
|
||||
- SKYNET_DB_PASS=${SKYNET_DB_PASS}
|
||||
- ACCOUNTS_EMAIL_URI=${ACCOUNTS_EMAIL_URI}
|
||||
- ACCOUNTS_JWKS_FILE=/conf/jwks.json
|
||||
- COOKIE_DOMAIN=${COOKIE_DOMAIN}
|
||||
- COOKIE_HASH_KEY=${COOKIE_HASH_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_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- SKYNET_ACCOUNTS_LOG_LEVEL=${SKYNET_ACCOUNTS_LOG_LEVEL}
|
||||
- KRATOS_ADDR=${KRATOS_ADDR}
|
||||
- OATHKEEPER_ADDR=${OATHKEEPER_ADDR}
|
||||
- SKYNET_ACCOUNTS_LOG_LEVEL=${SKYNET_ACCOUNTS_LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- ./docker/accounts/conf:/accounts/conf
|
||||
- ./docker/data/accounts:/data
|
||||
- ./docker/accounts/conf:/conf
|
||||
expose:
|
||||
- 3000
|
||||
networks:
|
||||
|
@ -50,50 +54,6 @@ services:
|
|||
ipv4_address: 10.10.10.70
|
||||
depends_on:
|
||||
- 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:
|
||||
build:
|
||||
|
@ -107,35 +67,16 @@ services:
|
|||
environment:
|
||||
- NEXT_PUBLIC_SKYNET_PORTAL_API=${SKYNET_PORTAL_API}
|
||||
- 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}
|
||||
volumes:
|
||||
- ./docker/data/dashboard/.next:/usr/app/.next
|
||||
networks:
|
||||
shared:
|
||||
ipv4_address: 10.10.10.85
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
- oathkeeper
|
||||
|
||||
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
|
||||
- mongo
|
||||
|
||||
cockroach:
|
||||
image: cockroachdb/cockroach:v20.2.3
|
||||
|
|
|
@ -16,6 +16,8 @@ services:
|
|||
logging: *default-logging
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./docker/data/nginx/blocker:/data/nginx/blocker
|
||||
expose:
|
||||
- 4000
|
||||
networks:
|
||||
|
|
|
@ -67,6 +67,7 @@ services:
|
|||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
|
||||
- ./docker/data/nginx/cache:/data/nginx/cache
|
||||
- ./docker/data/nginx/blocker:/data/nginx/blocker
|
||||
- ./docker/data/nginx/logs:/usr/local/openresty/nginx/logs
|
||||
- ./docker/data/nginx/skynet:/data/nginx/skynet:ro
|
||||
- ./docker/data/sia/apipassword:/data/sia/apipassword:ro
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
FROM golang:1.16.7
|
||||
LABEL maintainer="NebulousLabs <devs@nebulous.tech>"
|
||||
LABEL maintainer="SkynetLabs <devs@siasky.net>"
|
||||
|
||||
ENV GOOS linux
|
||||
ENV GOARCH amd64
|
||||
|
|
|
@ -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".
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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).
|
|
@ -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
|
|
@ -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 }}
|
||||
}
|
|
@ -10,6 +10,7 @@ COPY mo ./
|
|||
COPY libs /etc/nginx/libs
|
||||
COPY conf.d /etc/nginx/conf.d
|
||||
COPY conf.d.templates /etc/nginx/conf.d.templates
|
||||
COPY scripts /etc/nginx/scripts
|
||||
|
||||
CMD [ "bash", "-c", \
|
||||
"./mo < /etc/nginx/conf.d.templates/server.account.conf > /etc/nginx/conf.d/server.account.conf ; \
|
||||
|
|
|
@ -5,6 +5,32 @@ include /etc/nginx/conf.d/include/ssl-settings;
|
|||
include /etc/nginx/conf.d/include/init-optional-variables;
|
||||
|
||||
location / {
|
||||
proxy_redirect http://127.0.0.1/ https://$host/;
|
||||
proxy_pass http://oathkeeper:4455;
|
||||
proxy_pass http://dashboard:3000;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
#!/bin/bash
|
||||
|
||||
# TODO:
|
||||
#
|
||||
# 1. the purging should batch the skylinks to purge in a single command
|
||||
#
|
||||
# python example:
|
||||
#
|
||||
# cached_files_command = (
|
||||
# "find /data/nginx/cache/ -type f | xargs -r grep -Els '^Skynet-Skylink: ("
|
||||
# + "|".join(skylinks[i : i + batch_size])
|
||||
# + ")'"
|
||||
# )
|
||||
#
|
||||
# cached_files_count += int(
|
||||
# exec(
|
||||
# 'docker exec nginx bash -c "'
|
||||
# + cached_files_command
|
||||
# + ' | xargs -r rm -v | wc -l"'
|
||||
# )
|
||||
# )
|
||||
|
||||
# This script reads skylinks from a file and purges them from the Nginx cache.
|
||||
# It uses the atomic mkdir operation to create a lock on the file, under which
|
||||
# it copies the file and truncates it.
|
||||
|
||||
set -e # exit on first error
|
||||
|
||||
# The following variables define the paths to the file containing the skylinks
|
||||
# that need to be purged, the file in which we store the queued skylinks and the
|
||||
# lock directory that ensures the blocker API and the crontab don't manipulate
|
||||
# the same files concurrently.
|
||||
NGINX_PURGE_SKYLINKS_FILE="/data/nginx/blocker/skylinks.txt"
|
||||
NGINX_PURGE_SKYLINKS_QUEUED="/data/nginx/blocker/queued.txt"
|
||||
NGINX_PURGE_SKYLINKS_LOCK="/data/nginx/blocker/lock"
|
||||
NGINX_CACHE_DIR="/data/nginx/cache/"
|
||||
|
||||
purge_skylinks () {
|
||||
# read all skylinks from the queued skylinks file
|
||||
skylinks=()
|
||||
line_number=1
|
||||
while IFS="" read -r line || [ -n "$line" ];
|
||||
do
|
||||
if [[ $line =~ (^[a-zA-Z0-9_-]{46}$) ]]; then
|
||||
skylinks+=("$line")
|
||||
else
|
||||
echo "Incorrect skylink at line ${line_number}: $line"
|
||||
fi
|
||||
let line_number+=1
|
||||
done < $NGINX_PURGE_SKYLINKS_QUEUED;
|
||||
|
||||
for skylink in "${skylinks[@]}";
|
||||
do
|
||||
echo ".. ⌁ Purging skylink ${skylink}"
|
||||
cached_files_command="find ${NGINX_CACHE_DIR} -type f | xargs -r grep -Els '^Skynet-Skylink: ${skylink}'"
|
||||
bash -c "${cached_files_command} | xargs -r rm"
|
||||
|
||||
echo ".. ⌁ Skylink ${skylink} purged"
|
||||
echo "--------------------------------------------"
|
||||
done
|
||||
|
||||
# remove the queue file
|
||||
rm $NGINX_PURGE_SKYLINKS_QUEUED
|
||||
}
|
||||
|
||||
acquire_lock () {
|
||||
attempts=0
|
||||
locked=false
|
||||
until [ "$attempts" -ge 10 ]
|
||||
do
|
||||
if ! mkdir $NGINX_PURGE_SKYLINKS_LOCK 2>/dev/null
|
||||
then
|
||||
echo "skylinks file is locked, waiting..."
|
||||
((attempts++))
|
||||
sleep 1;
|
||||
else
|
||||
locked=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $locked
|
||||
then
|
||||
echo "failed to acquire lock, warrants investigation"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
release_lock () {
|
||||
rmdir $NGINX_PURGE_SKYLINKS_LOCK
|
||||
}
|
||||
|
||||
# if there is a queue file - purge all skylinks in that file from nginx cache
|
||||
if [ -f "$NGINX_PURGE_SKYLINKS_QUEUED" ]
|
||||
then
|
||||
echo "found queue file, purging skylinks from file"
|
||||
purge_skylinks
|
||||
echo "✓ Done"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# if there is no skylinks file - escape early
|
||||
if [ ! -f "$NGINX_PURGE_SKYLINKS_FILE" ]
|
||||
then
|
||||
echo "no skylinks found"
|
||||
echo "✓ Done"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# move the skylinks file to the queue under lock
|
||||
acquire_lock
|
||||
mv $NGINX_PURGE_SKYLINKS_FILE $NGINX_PURGE_SKYLINKS_QUEUED
|
||||
release_lock
|
||||
|
||||
# purge the skylinks from the queue file
|
||||
purge_skylinks
|
||||
echo "✓ Done"
|
||||
exit 1
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-html-link-for-pages": "off"
|
||||
}
|
||||
}
|
|
@ -10,6 +10,6 @@ RUN yarn --frozen-lockfile
|
|||
COPY public ./public
|
||||
COPY src ./src
|
||||
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"]
|
||||
|
|
|
@ -8,11 +8,10 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/metropolis": "4.5.0",
|
||||
"@ory/kratos-client": "0.5.4-alpha.1",
|
||||
"@fontsource/sora": "4.5.0",
|
||||
"@fontsource/source-sans-pro": "4.5.0",
|
||||
"@stripe/react-stripe-js": "1.6.0",
|
||||
"@stripe/stripe-js": "1.21.1",
|
||||
"@tailwindcss/forms": "0.3.4",
|
||||
"autoprefixer": "10.4.0",
|
||||
"classnames": "2.3.1",
|
||||
"copy-text-to-clipboard": "^3.0.1",
|
||||
|
@ -21,7 +20,7 @@
|
|||
"fast-levenshtein": "3.0.0",
|
||||
"formik": "2.2.9",
|
||||
"http-status-codes": "2.1.4",
|
||||
"ky": "0.25.1",
|
||||
"ky": "0.28.7",
|
||||
"next": "12.0.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"postcss": "8.3.11",
|
||||
|
@ -31,13 +30,16 @@
|
|||
"react-dom": "17.0.2",
|
||||
"react-toastify": "8.1.0",
|
||||
"skynet-js": "3.0.2",
|
||||
"stripe": "8.186.1",
|
||||
"stripe": "8.188.0",
|
||||
"superagent": "6.1.0",
|
||||
"swr": "1.0.1",
|
||||
"tailwindcss": "2.2.19",
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"resolutions": {
|
||||
"axios": "0.21.4"
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "0.3.4",
|
||||
"@tailwindcss/typography": "0.4.1",
|
||||
"eslint": "<8.0.0",
|
||||
"eslint-config-next": "12.0.3",
|
||||
"tailwindcss": "2.2.19"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,44 @@
|
|||
import * as React from "react";
|
||||
import { useFormik, getIn, setIn } from "formik";
|
||||
import classnames from "classnames";
|
||||
import SelfServiceMessages from "./SelfServiceMessages";
|
||||
|
||||
export default function SelfServiceForm({ flow, config, fieldsConfig, title, button = "Submit" }) {
|
||||
const fields = config.fields
|
||||
.filter((field) => !field.name.startsWith("traits.name")) // drop name fields
|
||||
.map((field) => ({ ...field, ...fieldsConfig[field.name] }))
|
||||
.sort((a, b) => (a.position < b.position ? -1 : 1));
|
||||
export default function SelfServiceForm({ fieldsConfig, onSubmit, title, validationSchema = null, button = "Submit" }) {
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const fields = fieldsConfig.sort((a, b) => (a.position < b.position ? -1 : 1));
|
||||
const formik = useFormik({
|
||||
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 (
|
||||
<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>}
|
||||
<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) => (
|
||||
<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">
|
||||
{fieldsConfig[field.name]?.label ?? field.name}
|
||||
{field.label ?? field.name}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
|
@ -27,61 +46,23 @@ export default function SelfServiceForm({ flow, config, fieldsConfig, title, but
|
|||
name={field.name}
|
||||
type={field.type}
|
||||
autoComplete={field.autoComplete}
|
||||
required={field.required}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
value={getIn(formik.values, field.name) ?? ""}
|
||||
className={classnames(
|
||||
"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(
|
||||
field?.messages?.length
|
||||
"border-gray-300 placeholder-gray-400 focus:ring-green-500 focus:border-green-500": !(
|
||||
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":
|
||||
Boolean(field?.messages?.length),
|
||||
formik.errors[field.name] && formik.touched[field.name],
|
||||
}
|
||||
)}
|
||||
/>
|
||||
|
||||
<SelfServiceMessages messages={field.messages} />
|
||||
|
||||
{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>
|
||||
{formik.errors[field.name] && formik.touched[field.name] && (
|
||||
<p className="mt-2 text-xs text-red-600">{formik.errors[field.name]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -94,9 +75,7 @@ export default function SelfServiceForm({ flow, config, fieldsConfig, title, but
|
|||
{button}
|
||||
</button>
|
||||
|
||||
<SelfServiceMessages messages={config.messages} />
|
||||
|
||||
{flow && <SelfServiceMessages messages={flow.messages} />}
|
||||
{messages.length > 0 && <SelfServiceMessages messages={messages} />}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -32,8 +32,9 @@ import classnames from "classnames";
|
|||
export default function SelfServiceMessages({ messages = [] }) {
|
||||
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
|
||||
key={index}
|
||||
className={classnames("mt-2 text-sm", {
|
||||
"text-red-600": type === "error",
|
||||
"text-blue-600": type === "info",
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import Head from "next/head";
|
||||
import ky from "ky/umd";
|
||||
import { useState } from "react";
|
||||
import config from "../../src/config";
|
||||
import accountsApi from "../services/accountsApi";
|
||||
|
||||
export default function Layout({ title, children }) {
|
||||
const [menuOpen, openMenu] = useState(false);
|
||||
|
@ -13,9 +12,8 @@ export default function Layout({ title, children }) {
|
|||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await ky.post("/logout");
|
||||
|
||||
window.location = `${config.kratos.browser}/self-service/browser/flows/logout`;
|
||||
await accountsApi.post("logout");
|
||||
router.push("/auth/login");
|
||||
} catch (error) {
|
||||
console.log(error); // todo: handle errors with a message
|
||||
}
|
||||
|
@ -35,23 +33,15 @@ export default function Layout({ title, children }) {
|
|||
<Link href="/">
|
||||
<a className="flex-shrink-0">
|
||||
<svg
|
||||
viewBox="19.88800048828125 37.1175193787 132.07760620117188 132.07760620117188"
|
||||
width={33}
|
||||
height={33}
|
||||
role="img"
|
||||
width="33"
|
||||
height="33"
|
||||
fill="#00C65E"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
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)" }}
|
||||
/>
|
||||
<title>Skynet</title>
|
||||
<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" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -152,7 +142,7 @@ export default function Layout({ title, children }) {
|
|||
aria-orientation="vertical"
|
||||
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">
|
||||
Settings
|
||||
</a>
|
||||
|
@ -281,7 +271,7 @@ export default function Layout({ title, children }) {
|
|||
</div>
|
||||
</div> */}
|
||||
<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">
|
||||
Settings
|
||||
</a>
|
||||
|
|
|
@ -44,7 +44,7 @@ export default function Table({ items, count, headers, mutate, actions, offset,
|
|||
if (offset < 0) setOffset(0);
|
||||
else if (offset >= count && count > 0) setOffset(Math.floor(count / pageSize - 1) * pageSize);
|
||||
else if (offset % pageSize) setOffset(offset - (offset % pageSize));
|
||||
}, [offset, pageSize, setOffset]);
|
||||
}, [count, offset, pageSize, setOffset]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
@ -104,7 +104,7 @@ export default function Table({ items, count, headers, mutate, actions, offset,
|
|||
))
|
||||
) : (
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,13 +1,4 @@
|
|||
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: {
|
||||
starter: { id: "starter", tier: 1, name: "Free", description: "Pin up to 100GB" },
|
||||
},
|
||||
|
|
|
@ -4,8 +4,13 @@ import { ToastContainer } from "react-toastify";
|
|||
import Head from "next/head";
|
||||
import "normalize.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "tailwindcss/tailwind.css";
|
||||
import "@fontsource/metropolis/all.css";
|
||||
import "../../styles/globals.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);
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import ky from "ky/umd";
|
||||
import ky from "ky";
|
||||
import Stripe from "stripe";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
|
@ -11,10 +11,10 @@ const getStripeCustomer = (stripeCustomerId = null) => {
|
|||
return stripe.customers.create();
|
||||
};
|
||||
|
||||
export default async (req, res) => {
|
||||
export default async function billingApi(req, res) {
|
||||
try {
|
||||
const authorization = req.headers.authorization; // authorization header from request
|
||||
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
|
||||
const cookie = req.headers.cookie; // cookie header from request
|
||||
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { cookie } }).json();
|
||||
const customer = await getStripeCustomer(stripeCustomerId);
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.id,
|
||||
|
@ -25,4 +25,4 @@ export default async (req, res) => {
|
|||
} catch ({ message }) {
|
||||
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import ky from "ky/umd";
|
||||
import ky from "ky";
|
||||
import Stripe from "stripe";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { isPaidTier } from "../../../services/tiers";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
const getStripeCustomer = async (user, authorization) => {
|
||||
const getStripeCustomer = async (user, cookie) => {
|
||||
if (user.stripeCustomerId) {
|
||||
return stripe.customers.retrieve(user.stripeCustomerId);
|
||||
}
|
||||
|
@ -13,12 +13,12 @@ const getStripeCustomer = async (user, authorization) => {
|
|||
const customer = await stripe.customers.create();
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
export default async (req, res) => {
|
||||
export default async function checkoutApi(req, res) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(StatusCodes.NOT_FOUND).end();
|
||||
}
|
||||
|
@ -30,8 +30,8 @@ export default async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const authorization = req.headers.authorization; // authorization header from request
|
||||
const user = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
|
||||
const cookie = req.headers.cookie; // cookie header from request
|
||||
const user = await ky("http://accounts:3000/user", { headers: { cookie } }).json();
|
||||
|
||||
if (isPaidTier(user.tier)) {
|
||||
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 } });
|
||||
}
|
||||
|
||||
const customer = await getStripeCustomer(user, authorization);
|
||||
const customer = await getStripeCustomer(user, cookie);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
|
@ -55,4 +55,4 @@ export default async (req, res) => {
|
|||
} catch (error) {
|
||||
res.status(StatusCodes.BAD_REQUEST).json({ error: { message: error.message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 } });
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
import user from "./user.json";
|
||||
|
||||
export default (req, res) => {
|
||||
res.json(user);
|
||||
};
|
|
@ -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"
|
||||
}
|
|
@ -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 });
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -1,5 +0,0 @@
|
|||
import stats from "./stats.json";
|
||||
|
||||
export default (req, res) => {
|
||||
res.json(stats);
|
||||
};
|
|
@ -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
|
||||
}
|
|
@ -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 });
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -1,65 +1,48 @@
|
|||
import Link from "next/link";
|
||||
import { Configuration, PublicApi } from "@ory/kratos-client";
|
||||
import config from "../../config";
|
||||
import { useRouter } from "next/router";
|
||||
import * as Yup from "yup";
|
||||
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 }));
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const flow = context.query.flow;
|
||||
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: {
|
||||
const fieldsConfig = [
|
||||
{
|
||||
name: "email",
|
||||
type: "text",
|
||||
label: "Email address",
|
||||
autoComplete: "email",
|
||||
position: 0,
|
||||
},
|
||||
password: {
|
||||
{
|
||||
name: "password",
|
||||
type: "password",
|
||||
label: "Password",
|
||||
autoComplete: "current-password",
|
||||
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 (
|
||||
<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">
|
||||
|
@ -82,14 +65,19 @@ export default function Login({ flow }) {
|
|||
<Link href="/auth/registration">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">sign up</a>
|
||||
</Link>{" "}
|
||||
if you don't have one yet
|
||||
if you don't have one yet
|
||||
</p>
|
||||
</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">
|
||||
<Link href="/recovery">
|
||||
<Link href="/auth/recovery">
|
||||
<a className="text-sm font-medium text-green-600 hover:text-green-500">Forgot your password?</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -1,57 +1,39 @@
|
|||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Configuration, PublicApi } from "@ory/kratos-client";
|
||||
import config from "../config";
|
||||
import SelfServiceForm from "../components/Form/SelfServiceForm";
|
||||
import * as Yup from "yup";
|
||||
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 }));
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const flow = context.query.flow;
|
||||
|
||||
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",
|
||||
const fieldsConfig = [
|
||||
{
|
||||
name: "email",
|
||||
type: "text",
|
||||
label: "Email address",
|
||||
autoComplete: "email",
|
||||
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 (
|
||||
<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">
|
||||
|
@ -84,12 +66,20 @@ export default function Recovery({ flow }) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<SelfServiceForm
|
||||
flow={flow}
|
||||
config={flow.methods.link.config}
|
||||
fieldsConfig={fieldsConfig}
|
||||
button="Send recovery link"
|
||||
/>
|
||||
{!success && (
|
||||
<SelfServiceForm
|
||||
fieldsConfig={fieldsConfig}
|
||||
validationSchema={validationSchema}
|
||||
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>
|
||||
);
|
||||
}
|
|
@ -1,88 +1,66 @@
|
|||
import Link from "next/link";
|
||||
import { Configuration, PublicApi } from "@ory/kratos-client";
|
||||
import { getIn } from "formik";
|
||||
import config from "../../config";
|
||||
import { useRouter } from "next/router";
|
||||
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 SelfServiceForm from "../../components/Form/SelfServiceForm";
|
||||
|
||||
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const flow = context.query.flow;
|
||||
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": {
|
||||
const fieldsConfig = [
|
||||
{
|
||||
name: "email",
|
||||
type: "text",
|
||||
label: "Email address",
|
||||
autoComplete: "email",
|
||||
position: 0,
|
||||
},
|
||||
password: {
|
||||
{
|
||||
name: "password",
|
||||
type: "password",
|
||||
label: "Password",
|
||||
autoComplete: "new-password",
|
||||
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 (
|
||||
<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">
|
||||
|
@ -109,7 +87,12 @@ export default function Registration({ flow }) {
|
|||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,12 +3,10 @@ import prettyBytes from "pretty-bytes";
|
|||
import { useState } from "react";
|
||||
import Layout from "../components/Layout";
|
||||
import Table from "../components/Table";
|
||||
import authServerSideProps from "../services/authServerSideProps";
|
||||
import { SkynetClient } from "skynet-js";
|
||||
import useAccountsApi from "../services/useAccountsApi";
|
||||
|
||||
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 getRelativeDate = ({ downloadedOn }) => dayjs(downloadedOn).format("YYYY-MM-DD HH:mm:ss");
|
||||
const headers = [
|
||||
|
@ -36,22 +34,15 @@ const headers = [
|
|||
];
|
||||
const actions = [];
|
||||
|
||||
export const getServerSideProps = authServerSideProps(async (context, api) => {
|
||||
const initialData = await api.get("user/downloads?pageSize=10&offset=0").json();
|
||||
|
||||
return { props: { initialData } };
|
||||
});
|
||||
|
||||
export default function Downloads({ initialData }) {
|
||||
export default function Downloads() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${offset}`, {
|
||||
initialData: offset === 0 ? initialData : undefined,
|
||||
const { data } = useAccountsApi(`user/downloads?pageSize=10&offset=${offset}`, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
// 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;
|
||||
useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${nextPageOffset}`);
|
||||
useAccountsApi(`user/downloads?pageSize=10&offset=${nextPageOffset}`);
|
||||
|
||||
return (
|
||||
<Layout title="Your downloads">
|
||||
|
|
|
@ -4,23 +4,14 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
|||
import prettyBytes from "pretty-bytes";
|
||||
import Link from "next/link";
|
||||
import Layout from "../components/Layout";
|
||||
import authServerSideProps from "../services/authServerSideProps";
|
||||
import { SkynetClient } from "skynet-js";
|
||||
import config from "../config";
|
||||
import useAccountsApi from "../services/useAccountsApi";
|
||||
import { isFreeTier } from "../services/tiers";
|
||||
import React from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
|
@ -99,13 +90,19 @@ function SkylinkList({ items = [], timestamp }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Home({ plans }) {
|
||||
const { data: user } = useAccountsApi(`${apiPrefix}/user`);
|
||||
const { data: stats } = useAccountsApi(`${apiPrefix}/user/stats`);
|
||||
const { data: downloads } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=3&offset=0`);
|
||||
const { data: uploads } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=3&offset=0`);
|
||||
export default function Home() {
|
||||
const { data: prices } = useAccountsApi("stripe/prices");
|
||||
const { data: user } = useAccountsApi("user");
|
||||
const { data: stats } = useAccountsApi("user/stats");
|
||||
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 (
|
||||
<Layout title="Dashboard">
|
||||
|
@ -135,7 +132,7 @@ export default function Home({ plans }) {
|
|||
<div className="ml-5 w-0 flex-1">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
import dayjs from "dayjs";
|
||||
import Layout from "../components/Layout";
|
||||
import ky from "ky/umd";
|
||||
import { useEffect, useState } from "react";
|
||||
import authServerSideProps from "../services/authServerSideProps";
|
||||
import ky from "ky";
|
||||
import * as React from "react";
|
||||
import classnames from "classnames";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import config from "../config";
|
||||
import useAccountsApi from "../services/useAccountsApi";
|
||||
import { isFreeTier, isPaidTier } from "../services/tiers";
|
||||
|
||||
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
|
||||
|
||||
const ActiveBadge = () => {
|
||||
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">
|
||||
|
@ -19,22 +16,23 @@ const ActiveBadge = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = authServerSideProps(async (context, api) => {
|
||||
const [user, stats, stripe] = await Promise.all([
|
||||
api.get("user").json(),
|
||||
api.get("user/stats").json(),
|
||||
api.get("stripe/prices").json(),
|
||||
]);
|
||||
const plans = [config.tiers.starter, ...stripe].sort((a, b) => a.tier - b.tier);
|
||||
export default function Payments() {
|
||||
const { data: user } = useAccountsApi("user");
|
||||
const { data: stats } = useAccountsApi("user/stats");
|
||||
const { data: prices } = useAccountsApi("stripe/prices");
|
||||
const [plans, setPlans] = React.useState([config.tiers.starter]);
|
||||
const [selectedPlan, setSelectedPlan] = React.useState(null);
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
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 (
|
||||
<Layout title="Payments">
|
||||
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
|
||||
|
|
|
@ -41,7 +41,7 @@ export default function Payments() {
|
|||
</a>
|
||||
</div>
|
||||
<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's included</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
<li className="flex space-x-3">
|
||||
{/* Heroicon name: solid/check */}
|
||||
|
@ -96,7 +96,7 @@ export default function Payments() {
|
|||
</a>
|
||||
</div>
|
||||
<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's included</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
<li className="flex space-x-3">
|
||||
{/* Heroicon name: solid/check */}
|
||||
|
@ -168,7 +168,7 @@ export default function Payments() {
|
|||
</a>
|
||||
</div>
|
||||
<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's included</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
<li className="flex space-x-3">
|
||||
{/* Heroicon name: solid/check */}
|
||||
|
@ -257,7 +257,7 @@ export default function Payments() {
|
|||
</a>
|
||||
</div>
|
||||
<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's included</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
<li className="flex space-x-3">
|
||||
{/* Heroicon name: solid/check */}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,16 +1,14 @@
|
|||
import dayjs from "dayjs";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { useState } from "react";
|
||||
import ky from "ky/umd";
|
||||
import ky from "ky";
|
||||
import { toast } from "react-toastify";
|
||||
import Layout from "../components/Layout";
|
||||
import Table from "../components/Table";
|
||||
import authServerSideProps from "../services/authServerSideProps";
|
||||
import { SkynetClient } from "skynet-js";
|
||||
import useAccountsApi from "../services/useAccountsApi";
|
||||
|
||||
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 getRelativeDate = ({ uploadedOn }) => dayjs(uploadedOn).format("YYYY-MM-DD HH:mm:ss");
|
||||
const headers = [
|
||||
|
@ -40,7 +38,7 @@ const actions = [
|
|||
{
|
||||
name: "Unpin Skylink",
|
||||
action: async ({ skylink }, mutate) => {
|
||||
await toast.promise(ky.delete(`/user/uploads/${skylink}`), {
|
||||
await toast.promise(ky.delete(`/api/user/uploads/${skylink}`), {
|
||||
pending: "Unpinning Skylink",
|
||||
success: "Skylink unpinned",
|
||||
error: (error) => error.message,
|
||||
|
@ -51,22 +49,15 @@ const actions = [
|
|||
},
|
||||
];
|
||||
|
||||
export const getServerSideProps = authServerSideProps(async (context, api) => {
|
||||
const initialData = await api.get("user/uploads?pageSize=10&offset=0").json();
|
||||
|
||||
return { props: { initialData } };
|
||||
});
|
||||
|
||||
export default function Uploads({ initialData }) {
|
||||
export default function Uploads() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data, mutate } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${offset}`, {
|
||||
initialData: offset === 0 ? initialData : undefined,
|
||||
const { data, mutate } = useAccountsApi(`user/uploads?pageSize=10&offset=${offset}`, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
// 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;
|
||||
useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${nextPageOffset}`);
|
||||
useAccountsApi(`user/uploads?pageSize=10&offset=${nextPageOffset}`);
|
||||
|
||||
return (
|
||||
<Layout title="Your uploads">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,31 +1,53 @@
|
|||
import Link from "next/link";
|
||||
import { Configuration, PublicApi } from "@ory/kratos-client";
|
||||
import config from "../config";
|
||||
import * as Yup from "yup";
|
||||
import { useRouter } from "next/router";
|
||||
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 error = context.query.error;
|
||||
const validationSchema = 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"),
|
||||
});
|
||||
|
||||
// No error was send, redirecting back to home.
|
||||
if (!error || typeof error !== "string") {
|
||||
console.log("No error ID found in URL, redirecting to homepage.");
|
||||
export default function Recover() {
|
||||
useAnonRoute(); // ensure user is not logged in
|
||||
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
try {
|
||||
const { status, data } = await kratos.getSelfServiceError(error);
|
||||
const onSubmit = async (values) => {
|
||||
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 (
|
||||
<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">
|
||||
|
@ -42,27 +64,15 @@ export default function Error({ errors }) {
|
|||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">An error occurred</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>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Set new password</h2>
|
||||
</div>
|
||||
|
||||
<SelfServiceForm
|
||||
fieldsConfig={fieldsConfig}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
button="Confirm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import ky from "ky";
|
||||
|
||||
const prefix = process.env.NEXT_PUBLIC_SKYNET_DASHBOARD_URL ?? "";
|
||||
|
||||
export default ky.create({ prefixUrl: `${prefix}/api` });
|
|
@ -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: {} };
|
||||
};
|
||||
}
|
|
@ -1,15 +1,21 @@
|
|||
import useSWR from "swr";
|
||||
import { useRouter } from "next/router";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
const fetcher = (url) =>
|
||||
fetch(url).then((res) => {
|
||||
const prefix = process.env.NEXT_PUBLIC_SKYNET_DASHBOARD_URL ?? "";
|
||||
|
||||
const fetcher = (url, router) => {
|
||||
return fetch(url).then((res) => {
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
export default function useAccountsApi(key, config) {
|
||||
return useSWR(key, fetcher, config);
|
||||
const router = useRouter();
|
||||
|
||||
return useSWR(`${prefix}/api/${key}`, (url) => fetcher(url, router), config);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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'" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,16 +1,3 @@
|
|||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
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;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
|
@ -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 = {
|
||||
purge: ["./src/**/*.js"],
|
||||
purge: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
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: {
|
||||
fontFamily: {
|
||||
sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"],
|
||||
sans: ["Sora", ...defaultTheme.fontFamily.sans],
|
||||
content: ["Source\\ Sans\\ Pro", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {
|
||||
backgroundColor: ["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
|
@ -5,3 +5,4 @@
|
|||
0 4 * * * /home/user/skynet-webportal/scripts/db_backup.sh 1 >> /home/user/skynet-webportal/logs/db_backup_`date +"%Y-%m-%d-%H%M"`.log 2 > &1
|
||||
0 5 * * * /home/user/skynet-webportal/scripts/es_cleaner.py 1 http://localhost:9200
|
||||
15 * * * * /home/user/skynet-webportal/scripts/nginx-prune.sh
|
||||
*/30 * * * * docker exec nginx /etc/nginx/scripts/purge-blocklist.sh
|
||||
|
|
Reference in New Issue