Initial commit

Created from https://vercel.com/new
This commit is contained in:
ryoojiz 2024-04-03 10:15:51 +00:00
commit 34c9712221
237 changed files with 36973 additions and 0 deletions

47
.env.example Normal file
View file

@ -0,0 +1,47 @@
NEXT_PUBLIC_FIREBASE_PUBLIC_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_ROOT_URL=http://localhost:3000
NEXT_PUBLIC_SHA=development
NEXT_PUBLIC_BRANCH=local
# Stripe Payments
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_STRIPE_PRICE_ID=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
MONGODB_URI="mongodb://localhost:27017"
MONGODB_DB=""
# Discord Interactions API
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
DISCORD_APP_ID=""
DISCORD_TOKEN=""
DISCORD_PUBLIC_KEY=""
DISCORD_GUILD_ID=""
# Roblox OAuth2 API
NEXT_PUBLIC_ROBLOX_CLIENT_ID=""
ROBLOX_CLIENT_SECRET=""
# Mail
SENDGRID_API_KEY=
# Firebase Authentication and Image Storage
FIREBASE_ADMIN_auth_provider_x509_cert_url=
FIREBASE_ADMIN_auth_uri=
FIREBASE_ADMIN_client_email=
FIREBASE_ADMIN_client_id=
FIREBASE_ADMIN_client_x509_cert_url=
# (Encoded in base64)
FIREBASE_ADMIN_private_key=
FIREBASE_ADMIN_private_key_id=
FIREBASE_ADMIN_project_id=
FIREBASE_ADMIN_token_uri=
FIREBASE_ADMIN_type=

6
.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"react/prop-types": 0
}
}

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.development
.env.production
.env.test
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

31
.prettierrc Normal file
View file

@ -0,0 +1,31 @@
{
"singleQuote": true,
"printWidth": 120,
"editor.formatOnSave": true,
"proseWrap": "always",
"tabWidth": 2,
"requireConfig": false,
"useTabs": false,
"trailingComma": "none",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"semi": true,
"singleAttributePerLine": true,
"importOrder": [
"^react$",
"^@chakra-ui/react",
"^@chakra-ui/icons",
"^react-icons/(.*)$",
"<THIRD_PARTY_MODULES>",
"^@/lib/(.*)$",
"^@/contexts/(.*)$",
"^@/hooks/(.*)$",
"^@/util/(.*)$",
"^@/assets/(.*)$",
"^@/layouts/(.*)$",
"^@/components/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

21
LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Pete Pongpeauk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# R&C XCS - Card Access Management Platform
R&C XCS is a card access management platform that allows users to manage their card access to access points within experiences on the Roblox platform.
### Features:
- Create and manage access points
- Organization and location-based access control
- Different allow-list parameters (e.g. card numbers, user ID, group ID assocation, etc.)
- Access groups
- Scan logs
- Member management
- Invitation links for organizations and registration
- ...and more!
This project uses the following technologies:
- [React](https://reactjs.org/)
- [Next.js](https://nextjs.org/)
- [MongoDB](https://www.mongodb.com/)
The backend is *serverless* and is bundled with Next.js in the `src/pages/api` directory.
## Installation
### Prerequisites
- [Node.js](https://nodejs.org/en/) (v20.8.0 or higher)
- [Bun](https://bun.sh/) (1.0.7 or higher, recommended) or [npm](https://www.npmjs.com/) (9.8.0 or higher)
- [MongoDB Community Server](https://www.mongodb.com/)
### Setup
1. Clone the repository
2. Run `bun install` or `npm install` to install dependencies
3. Clone `.env.example` to `.env` and fill in the values
3. Run `bun dev` or `npm run dev` to start the development server
## Contributing
This project is open to contributions. I don't have a contributing guide yet, but feel free to open a pull request and I'll review it.
## License
This project is licensed under the MIT license. See [LICENSE.md](LICENSE.md) for more information.

BIN
bun.lockb Executable file

Binary file not shown.

15
components.json Normal file
View file

@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/globals.css",
"baseColor": "slate",
"cssVariables": false
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

103
next.config.js Normal file
View file

@ -0,0 +1,103 @@
/** @type {import('next').NextConfig} */
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
})
// const withPWA = require('next-pwa')({
// dest: 'public',
// register: true,
// skipWaiting: true,
// disable: true,
// });
module.exports = withBundleAnalyzer({
reactStrictMode: true,
async rewrites() {
return [
// legacy oauth endpoints
{
source: '/platform/verify/:slug*',
destination: '/verify/:slug*'
},
{
source: '/@:username',
destination: '/users/:username'
},
{
source: '/api/v1/roblox/users/:slug*',
destination: 'https://users.roblox.com/:slug*'
},
{
source: '/api/v1/roblox/groups/:slug*',
destination: 'https://groups.roblox.com/:slug*'
},
{
source: '/api/v1/roblox/games/:slug*',
destination: 'https://games.roblox.com/:slug*'
},
{
source: '/api/v1/roblox/thumbnails/:slug*',
destination: 'https://thumbnails.roblox.com/:slug*'
},
{
source: '/api/v1/ip-api/:slug*',
destination: 'http://ip-api.com/:slug*'
}
];
},
async redirects() {
return [
{
source: '/login',
destination: '/auth/login',
permanent: true
},
{
source: '/signup',
destination: '/auth/signup',
permanent: true
},
{
source: '/logout',
destination: '/auth/logout',
permanent: true
},
{
source: '/platform',
destination: '/platform/home',
permanent: true
},
{
source: '/platform/:slug*',
destination: '/:slug*',
permanent: false
},
{
source: '/users/:slug',
destination: '/@:slug',
permanent: false
},
{
source: '/user/:slug',
destination: '/@:slug',
permanent: false
},
{
source: '/invite/:slug*',
destination: '/invitation/:slug*',
permanent: false
},
{
source: '/AxesysAPI/:slug*',
destination: '/api/v1/axesys/:slug*',
permanent: false
},
{
source: '/api/v1/AxesysAPI/:slug*',
destination: '/api/v1/axesys/:slug*',
permanent: false
}
];
}
});

79
package.json Normal file
View file

@ -0,0 +1,79 @@
{
"name": "xcs4",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"analyze": "cross-env ANALYZE=true next build",
"analyze:server": "cross-env BUNDLE_ANALYZE=server next build",
"analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build"
},
"dependencies": {
"@chakra-ui/icons": "^2.0.19",
"@chakra-ui/next-js": "^2.1.5",
"@chakra-ui/react": "^2.8.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@monaco-editor/react": "^4.5.1",
"@mui/material": "^5.13.7",
"@mui/x-data-grid": "^6.9.2",
"@sendgrid/mail": "^7.7.0",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-table": "^8.9.3",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/gm": "^1.25.1",
"@types/node": "^20.5.0",
"@types/randomstring": "^1.1.8",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@types/uuid": "^9.0.2",
"autoprefixer": "10.4.14",
"chakra-react-select": "^4.7.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"discord-api-types": "^0.37.50",
"eslint": "8.44.0",
"eslint-config-next": "13.4.7",
"firebase": "^9.23.0",
"firebase-admin": "^11.9.0",
"formik": "^2.4.2",
"framer-motion": "^10.13.0",
"generate-api-key": "^1.0.2",
"gray-matter": "^4.0.3",
"lru-cache": "7.12.0",
"mergician": "^1.1.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mongodb": "^5.6.0",
"next": "13.4.7",
"next-pwa": "^5.6.0",
"nextjs-progressbar": "^0.0.16",
"postcss": "8.4.24",
"prettier": "^3.0.0",
"randomstring": "^1.3.0",
"react": "18.2.0",
"react-data-grid": "^7.0.0-beta.34",
"react-dom": "18.2.0",
"react-fast-marquee": "^1.6.0",
"react-firebase": "^2.2.8",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.10.1",
"react-markdown": "^9.0.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"request-ip": "^3.3.0",
"sharp": "^0.32.6",
"tailwind-merge": "^1.13.2",
"tailwindcss": "3.3.2",
"tailwindcss-animate": "^1.0.6",
"tweetnacl": "^1.0.3",
"typescript": "5.1.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^13.4.13",
"cross-env": "^7.0.3"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View file

@ -0,0 +1,81 @@
---
title: 'Platform Updates 2023-08-13'
date: '2023-08-13'
thumbnail: '/images/blog/launch/1.png'
thumbnailAlt: 'R&C XCS'
author: 'restrafes'
authorImage: '/images/authors/restrafes.png'
category: 'Updates'
---
Hello, and welcome to the first ever blog post on the platform! For our first post, we'll be going over some of the
updates that have been made to the platform since our beta launch.
Let's get started!
## Adding members just got an upgrade!
We've updated the member add flow to be more intuitive and easier to use. The new flow is shown below:
![Member flow](/images/blog/updates-2023-08-13/1.jpeg)
## 🔥🔥 Card member types 🔥🔥
In an ongoing effort to make XCS a full replacement to traditional access point readers, we've added a new member type:
card numbers! You can now specify what card numbers are accepted for each access point. Ranges are supported, so you
don't need to add each card number individually (e.g. 1-24.)
## In-app invitations have arrived!
We've added an in-app invitation system for organizations. This allows you to invite members to your organization
without having to share a link. Of course, you can still share a link if you want to.
![Organization invitations screen](/images/blog/updates-2023-08-13/2.jpeg)
## Access group priority levels
You can now specify a priority level for each access group. This allows you to specify which access groups' scan data
take precedence over others.
For example, if you have an access group with a priority level of 1 with the following scan data:
{
"isRestricted": true,
"allowedFloors": [1, 3]
}
and another access group with a priority level of 2 with the following scan data:
{
"isRestricted": false,
"allowedFloors": [1, 4, 5]
}
the following scan data will be returned for a member with both access groups:
{
"isRestricted": false,
"allowedFloors": [1, 3, 4, 5]
}
## Beta testers now get a sweet new badge! 🎉
We've added a new badge for beta testers! If you registered your XCS account before the platform was launched to the
public, you should have the badge already.
![Beta tester badge](/images/blog/updates-2023-08-13/3.jpeg)
## Other features & changes
- You are now able to upload custom icons to your organization.
- Organizations now get a public page.
That wraps up this blog post! We hope you enjoy the features as much as we enjoyed making them. If you have any feedback
or suggestions, please let us know in our [official Discord server!](https://discord.gg/BWVa3yE9M3)
Until next time,
restrafes

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon-alt.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon-alt2.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/images/hero1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/images/hero2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

BIN
public/images/hero3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
public/images/hero4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

BIN
public/images/hero5.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

BIN
public/images/hero6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

BIN
public/images/home-hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/images/login1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
public/images/login2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

BIN
public/images/login4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/images/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

52
public/manifest.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "R&C XCS",
"short_name": "XCS",
"theme_color": "#fff",
"background_color": "#000",
"display": "standalone",
"orientation": "",
"scope": "/",
"start_url": "/auth/login",
"icons": [
{
"src": "images/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "images/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "images/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "images/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "images/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "images/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "images/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "images/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/site.webmanifest Normal file
View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

1
src/blacklist.ts Normal file
View file

@ -0,0 +1 @@
export const blacklist = {};

View file

@ -0,0 +1,652 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Badge,
Box,
Button,
Flex,
FormControl,
FormHelperText,
FormLabel,
IconButton,
Input,
InputGroup,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Portal,
Skeleton,
SkeletonText,
Spacer,
Stack,
Switch,
Table, TableCaption, TableContainer, Tbody, Td, Text,
Th, Thead,
Tr,
VStack,
chakra,
useColorModeValue,
useDisclosure,
useToast
} from '@chakra-ui/react';
import { IoIosCreate, IoIosRemoveCircle } from 'react-icons/io';
import { IoSave } from 'react-icons/io5';
import { MdEditSquare } from 'react-icons/md';
import Editor from '@monaco-editor/react';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
import DeleteDialog from '@/components/DeleteDialog';
import CreateAccessGroupDialog from './CreateAccessGroupDialog';
const ChakraEditor = chakra(Editor);
export default function AccessGroupEditModal({
isOpen,
onOpen,
onClose,
onRefresh,
clientMember,
groups,
organization,
location,
onGroupRemove
}: any) {
const { user } = useAuthContext();
const toast = useToast();
const [focusedGroup, setFocusedGroup] = useState<any>(null);
const themeBorderColor = useColorModeValue('gray.200', 'gray.700');
const groupSearchRef = useRef<any>(null);
const [filteredGroups, setFilteredGroups] = useState<any>([]);
const editButtonsRef = useRef<any>(null);
const {
isOpen: deleteGroupDialogOpen,
onOpen: deleteGroupDialogOnOpen,
onClose: deleteGroupDialogOnClose
} = useDisclosure();
const { isOpen: createModalOpen, onOpen: createModalOnOpen, onClose: createModalOnClose } = useDisclosure();
const filterGroups = useCallback((query: string) => {
if (!query) return groups;
return Object.keys(groups)
.filter((group: any) => groups[group].name.toLowerCase().includes(query.toLowerCase()))
.map((group: any) => groups[group]);
}, [groups]);
useEffect(() => {
setFilteredGroups(groups || {});
}, [groups]);
useEffect(() => {
if (!organization) return;
setFilteredGroups(filterGroups(groupSearchRef?.current?.value));
if (focusedGroup) {
setFocusedGroup(Object.values(groups as any).find((group: any) => group.id === focusedGroup.id));
}
}, [organization]);
return (
<>
<DeleteDialog
isOpen={deleteGroupDialogOpen}
onClose={deleteGroupDialogOnClose}
title="Delete Access Group"
body={`Are you sure you want to delete the ${focusedGroup?.name} access group from this organization? All members and access points that have this access group will be updated to reflect this change.`}
buttonText="Delete"
onDelete={() => {
deleteGroupDialogOnClose();
onGroupRemove(focusedGroup);
setFocusedGroup(null);
}}
/>
<CreateAccessGroupDialog
isOpen={createModalOpen}
onClose={createModalOnClose}
organization={organization}
location={location}
onCreate={(group: any) => {
onRefresh();
createModalOnClose();
}}
/>
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
closeOnOverlayClick={false}
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent
maxW={{ base: 'full', lg: 'container.xl' }}
bg={useColorModeValue('white', 'gray.800')}
h="100%"
>
<ModalHeader>Manage Access Groups</ModalHeader>
<ModalCloseButton />
<ModalBody
w={'full'}
pb={0}
h="100%"
>
<VStack
w="100%"
h="100%"
overflow={{
base: 'auto',
xl: 'hidden'
}}
overscrollBehavior={'contain'}
>
<Stack
w="100%"
mb={4}
direction={{ base: 'column', md: 'row' }}
>
<FormControl w={{ base: 'full', md: '300px' }}>
<FormLabel>Search Access Group</FormLabel>
<Input
placeholder={'Search for an access group...'}
ref={groupSearchRef}
onChange={(e) => {
if (e.target?.value) {
setFilteredGroups(filterGroups(e.target?.value));
} else {
setFilteredGroups(groups);
}
}}
/>
</FormControl>
<Spacer />
<Button
alignSelf={{
base: 'normal',
md: 'flex-end'
}}
onClick={createModalOnOpen}
leftIcon={<IoIosCreate />}
>
New Access Group
</Button>
</Stack>
<Flex
w={'full'}
justify={'space-between'}
flexDir={{ base: 'column', xl: 'row' }}
h="full"
overflow="auto"
overscrollBehavior={'contain'}
>
<TableContainer
py={2}
minH={{ base: '320px', xl: '100%' }}
overflowY={'auto'}
overscrollBehavior={'contain'}
flexGrow={1}
px={4}
>
<Table size={{ base: 'sm', md: 'sm' }}>
<Thead>
<Tr>
<Th>Name</Th>
<Th isNumeric>Actions</Th>
</Tr>
</Thead>
<Tbody>
{organization ? (
Object.keys(filteredGroups).map((group: any) => (
<Tr key={group}>
<Td>
<Box my={2}>
<Text
fontWeight="bold"
alignItems={'center'}
display={'flex'}
>
{filteredGroups[group].name}
{filteredGroups[group].config?.openToEveryone && (
<Badge
as={'span'}
ml={2}
>
Everyone
</Badge>
)}
<Badge
as={'span'}
ml={2}
colorScheme={filteredGroups[group].config?.active ? 'green' : 'red'}
>
{filteredGroups[group].config?.active ? 'Active' : 'Inactive'}
</Badge>
</Text>
{filteredGroups[group].description && (
<Text
color={'gray.500'}
maxW={'384px'}
isTruncated
>
{filteredGroups[group].description}
</Text>
)}
</Box>
</Td>
<Td isNumeric>
<Button
size="sm"
leftIcon={<MdEditSquare />}
onClick={() => {
setFocusedGroup(filteredGroups[group]);
}}
>
Manage
</Button>
<IconButton
aria-label="Delete Access Group"
size="sm"
colorScheme="red"
ml={2}
icon={<IoIosRemoveCircle />}
onClick={() => {
setFocusedGroup(filteredGroups[group]);
deleteGroupDialogOnOpen();
}}
/>
</Td>
</Tr>
))
) : (
<>
{Array.from(Array(8).keys()).map((i) => (
<Tr key={i}>
<Td>
<SkeletonText
noOfLines={1}
spacing="4"
skeletonHeight={4}
/>
</Td>
<Td isNumeric>
<SkeletonText
noOfLines={1}
spacing="4"
skeletonHeight={4}
/>
</Td>
</Tr>
))}
</>
)}
</Tbody>
{(!Object.keys(groups || {})?.length || !Object.keys(filteredGroups || {})?.length) && (
<TableCaption>No access groups found.</TableCaption>
)}
</Table>
</TableContainer>
{/* Edit Group */}
<Skeleton
isLoaded={organization}
rounded={'lg'}
minW={{
base: 'unset',
sm: 'unset',
lg: '512px'
}}
flexBasis={1}
>
<Flex
p={6}
rounded={'lg'}
border={'1px solid'}
borderColor={themeBorderColor}
h={'full'}
overflowY={'auto'}
overscrollBehavior={'contain'}
>
{!focusedGroup || !organization ? (
<Text
m={'auto'}
variant={'subtext'}
fontWeight={'bold'}
>
Select an access group to manage.
</Text>
) : (
<Flex
flexDir={'column'}
w={'full'}
>
{/* Header */}
<Flex
align={'center'}
h={'fit-content'}
>
<Flex flexDir={'column'}>
<Flex align={'center'}>
<Text
as={'h2'}
fontSize={'2xl'}
fontWeight={'bold'}
>
{focusedGroup?.name}
</Text>
</Flex>
</Flex>
</Flex>
{/* Body */}
<Formik
enableReinitialize={true}
initialValues={{
name: focusedGroup?.name,
description: focusedGroup?.description || '',
priority: focusedGroup?.priority || 1,
scanData: JSON.stringify(focusedGroup?.scanData, null, 3),
// config
configActive: focusedGroup?.config?.active,
configOpenToEveryone: focusedGroup?.config?.openToEveryone
}}
onSubmit={(values, actions) => {
user.getIdToken().then((token: string) => {
fetch(`/api/v1/organizations/${organization?.id}/access-groups/${focusedGroup?.id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: values?.name,
locationId: location?.id,
description: values?.description,
scanData: values?.scanData,
priority: values?.priority,
config: {
active: values?.configActive,
openToEveryone: values?.configOpenToEveryone
}
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
onRefresh();
})
.catch((error) => {
toast({
title: 'There was an error updating the access group.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Form
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
justifyContent: 'space-between'
}}
>
<Flex
flexDir={'column'}
mt={4}
w={'full'}
pb={8}
>
<Stack>
<Stack
direction={{
base: 'column',
md: 'column'
}}
>
<Field name="name">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Name</FormLabel>
<Input
{...field}
type={'text'}
variant={'outline'}
placeholder={'Access Group Name'}
autoComplete={'off'}
autoCorrect={'off'}
/>
</FormControl>
)}
</Field>
<Field name="description">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Short Description</FormLabel>
<Input
{...field}
type={'text'}
variant={'outline'}
placeholder={'Short Description'}
autoComplete={'off'}
autoCorrect={'off'}
/>
</FormControl>
)}
</Field>
<Field name="priority">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Priority</FormLabel>
<InputGroup>
<NumberInput
{...field}
autoComplete="off"
placeholder="Priority"
variant={'outline'}
min={1}
defaultValue={1}
onChange={(value) => {
form.setFieldValue('priority', value);
}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</InputGroup>
</FormControl>
)}
</Field>
</Stack>
<Stack
direction={{
base: 'column',
md: 'row'
}}
py={2}
w={'fit-content'}
>
<Field name="configActive">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Active</FormLabel>
<InputGroup>
<Switch
{...field}
placeholder="Active"
variant={'outline'}
width={'fit-content'}
isChecked={form.values?.configActive}
onChange={(e) => {
form.setFieldValue('configActive', e.target.checked);
}}
/>
</InputGroup>
<FormHelperText>Whether or not this access group is active.</FormHelperText>
</FormControl>
)}
</Field>
<Field name="configOpenToEveryone">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Everyone</FormLabel>
<InputGroup>
<Switch
{...field}
colorScheme={'red'}
placeholder="Everyone"
variant={'outline'}
width={'fit-content'}
isChecked={form.values?.configOpenToEveryone}
onChange={(e) => {
form.setFieldValue('configOpenToEveryone', e.target.checked);
}}
/>
</InputGroup>
<FormHelperText>
Whether or not this access group is open to everyone whose access is granted.
</FormHelperText>
</FormControl>
)}
</Field>
</Stack>
<Field name="scanData">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Scan Data</FormLabel>
<InputGroup>
<Box
border={'1px solid'}
borderColor={themeBorderColor}
borderRadius={'lg'}
w={'full'}
overflow={'hidden'}
>
<ChakraEditor
{...field}
height="240px"
width="100%"
p={4}
language="json"
theme={useColorModeValue('vs-light', 'vs-dark')}
options={{
minimap: {
enabled: false
}
}}
value={form.values?.scanData}
onChange={(value) => {
form.setFieldValue('scanData', value);
}}
/>
</Box>
</InputGroup>
<FormHelperText>
This is the data that will be returned when a user under this access group
scans their card. (User scan data takes priority over access group scan data
when it is merged).
</FormHelperText>
</FormControl>
)}
</Field>
</Stack>
</Flex>
<Portal containerRef={editButtonsRef}>
<Stack
direction={'row'}
spacing={4}
>
<Button
isLoading={props.isSubmitting}
leftIcon={<IoSave />}
type={'submit'}
onClick={() => {
props.handleSubmit();
}}
onSubmit={() => {
props.handleSubmit();
}}
>
Save Changes
</Button>
<Button
colorScheme="red"
leftIcon={<IoIosRemoveCircle />}
onClick={() => {
setFocusedGroup(focusedGroup);
deleteGroupDialogOnOpen();
}}
>
Delete
</Button>
</Stack>
</Portal>
</Form>
)}
</Formik>
</Flex>
)}
</Flex>
</Skeleton>
</Flex>
</VStack>
</ModalBody>
<ModalFooter>
<Stack
direction={{ base: 'column', md: 'row' }}
spacing={4}
>
<Box ref={editButtonsRef} />
<Button
colorScheme="black"
onClick={onClose}
>
Close
</Button>
</Stack>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View file

@ -0,0 +1,136 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useRef } from 'react';
import {
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { useAuthContext } from '@/contexts/AuthContext';
import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router';
export default function CheckActivationCodeModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const toast = useToast();
const initialRef = useRef(null);
const { user } = useAuthContext();
const { push } = useRouter();
return (
<>
<Formik
initialValues={{ code: '' }}
onSubmit={async (values, actions) => {
const reformatCode = values.code.replace(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/`, '');
await actions.setValues({ 'code': reformatCode });
await fetch(`/api/v1/activation/${encodeURIComponent(reformatCode || 0)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
onClose();
push(`/auth/activate/${reformatCode}`)
})
.catch((error) => {
toast({
title: 'Error',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Activation Code</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="code">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Activation Code</FormLabel>
<Input
{...field}
ref={initialRef}
value={field.value}
variant={'outline'}
placeholder={'Code'}
autoComplete={'off'}
autoCorrect={'off'}
/>
</FormControl>
)}
</Field>
</VStack>
<Text
fontSize={'sm'}
color={"gray.500"}
pt={2}
>
Have an activation code? Enter it here to create an account.
</Text>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Submit
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,164 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useRef } from 'react';
import {
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
export default function CreateAccessGroupDialog({
isOpen,
onClose,
organization,
location,
onCreate
}: {
isOpen: boolean;
onClose: () => void;
organization: any;
location?: any;
onCreate: (location: any) => void;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
return (
<>
<Formik
initialValues={{ name: '', description: '', scanData: {} }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/organizations/${organization.id}/access-groups`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: values.name,
locationId: location?.id,
description: values.description,
scanData: {}
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
actions.resetForm();
onClose();
onCreate(data.id);
})
.catch((error) => {
toast({
title: 'There was an error creating the access group.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>New Access Group</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
type={'text'}
variant={'outline'}
placeholder={'Access Group Name'}
ref={initialRef}
autoCorrect={'off'}
/>
</FormControl>
)}
</Field>
<Field name="description">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Short Description</FormLabel>
<Textarea
{...field}
variant={'outline'}
placeholder={'Access Group Short Description'}
maxH={'1rem'}
autoComplete={'off'}
autoCorrect={'off'}
/>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Create
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,204 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useRef } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Field, Form, Formik } from 'formik';
import { getRandomAccessPointName } from '@/lib/utils';
import { useAuthContext } from '@/contexts/AuthContext';
import { AccessPoint, Location } from '@/types';
import { Select } from 'chakra-react-select';
export default function CreateAccessPointDialog({
isOpen,
onClose,
location,
onCreate,
accessPoints
}: {
isOpen: boolean;
onClose: () => void;
location: Location;
onCreate: (location: any) => void;
accessPoints: AccessPoint[];
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
const namePlaceholder = getRandomAccessPointName();
return (
<>
<Formik
initialValues={{ name: '', description: '', template: null as null | { label: string, value: any } }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/locations/${location.id}/access-points`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: values.name,
description: values.description,
locationId: location.id,
templateId: values.template?.value || null
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
actions.resetForm();
onClose();
onCreate(data.accessPointId);
})
.catch((error) => {
toast({
title: 'There was an error creating the access point.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>New Access Point</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="name">
{({ field, form }: any) => (
<FormControl isRequired>
<FormLabel>Name</FormLabel>
<Input
{...field}
ref={initialRef}
variant={'outline'}
placeholder={namePlaceholder || 'Access Point Name'}
autoComplete='off'
/>
</FormControl>
)}
</Field>
<Field name="template">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Copy Configuration from Access Point</FormLabel>
<Select
{...field}
options={(accessPoints || []).map((ap: AccessPoint) => ({
value: ap.id,
label: ap.name
})) || []}
placeholder={'Access Point (optional)'}
onChange={(value) => {
form.setFieldValue('template', value);
}}
value={field.value}
single={true}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
isClearable={true}
/>
<FormHelperText>
Use an existing access point&apos;s configuration.
</FormHelperText>
</FormControl>
)}
</Field>
<Field name="description">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Description</FormLabel>
<Textarea
{...field}
variant={'outline'}
placeholder={'Access Point Description'}
maxH={'200px'}
/>
<FormHelperText>
This access point will be created under the{' '}
<Text
as={'span'}
fontWeight={'bold'}
>
{location?.name}
</Text>{' '}
location.
</FormHelperText>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Create
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,185 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useRef } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
import { getRandomLocationName } from '@/lib/utils';
export default function CreateLocationDialog({
isOpen,
onClose,
selectedOrganization,
onCreate
}: {
isOpen: boolean;
onClose: () => void;
selectedOrganization: any;
onCreate: (location: any) => void;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
const namePlaceholder = getRandomLocationName();
return (
<>
<Formik
initialValues={{ name: '', description: '' }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch('/api/v1/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: values.name,
description: values.description,
organizationId: selectedOrganization.id
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
actions.resetForm();
onClose();
onCreate(data.locationId);
})
.catch((error) => {
toast({
title: 'There was an error creating the location.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>New Location</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
variant={'outline'}
placeholder={namePlaceholder || 'Location Name'}
ref={initialRef}
autoComplete='off'
/>
</FormControl>
)}
</Field>
{/* <Field name="organization">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Organization</FormLabel>
<Input
{...field}
variant={"outline"}
value={selectedOrganization.name}
isDisabled={true}
/>
</FormControl>
)}
</Field> */}
<Field name="description">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Description</FormLabel>
<Textarea
{...field}
variant={'outline'}
placeholder={'Location Description'}
maxH={'200px'}
/>
<FormHelperText>
This location will be created under the{' '}
<Text
as={'span'}
fontWeight={'bold'}
>
{selectedOrganization?.name}
</Text>{' '}
organization.
</FormHelperText>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Create
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,176 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useRef } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Field, Form, Formik } from 'formik';
import NextLink from 'next/link';
import { useAuthContext } from '@/contexts/AuthContext';
import { getRandomOrganizationName } from '@/lib/utils';
export default function CreateOrganizationDialog({
isOpen,
onClose,
onCreate
}: {
isOpen: boolean;
onClose: () => void;
onCreate: (location: any) => void;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
const namePlaceholder = getRandomOrganizationName();
return (
<>
<Formik
initialValues={{ name: '' }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch('/api/v1/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: values.name
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
actions.resetForm();
onClose();
onCreate(data.organizationId);
})
.catch((error) => {
toast({
title: 'There was an error creating the organization.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>New Organization</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
variant={'outline'}
placeholder={namePlaceholder || 'Organization Name'}
ref={initialRef}
autoComplete={'off'}
/>
<FormHelperText>
By creating an organization, you agree to our{' '}
<Text as={'span'}>
<Link
as={NextLink}
href={'/legal/terms'}
target={'_blank'}
textDecor={'underline'}
textUnderlineOffset={4}
whiteSpace={'nowrap'}
>
Terms of Use
</Link>
</Text>{' '}
and{' '}
<Text as={'span'}>
<Link
as={NextLink}
href={'/legal/privacy'}
target={'_blank'}
textDecor={'underline'}
textUnderlineOffset={4}
whiteSpace={'nowrap'}
>
Privacy Policy
</Link>
</Text>
.
</FormHelperText>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Create
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,85 @@
import * as React from 'react';
import { Table, Tbody, Td, Th, Thead, Tr, chakra } from '@chakra-ui/react';
import { TriangleDownIcon, TriangleUpIcon } from '@chakra-ui/icons';
import {
ColumnDef,
SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
export type DataTableProps<Data extends object> = {
data: Data[];
columns: ColumnDef<Data, any>[];
};
export function DataTable<Data extends object>({ data, columns }: DataTableProps<Data>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting
}
});
return (
<Table>
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
// see https://tanstack.com/table/v8/docs/api/core/column-def#meta to type this correctly
const meta: any = header.column.columnDef.meta;
return (
<Th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
isNumeric={meta?.isNumeric}
>
{flexRender(header.column.columnDef.header, header.getContext())}
<chakra.span pl={'4'}>
{header.column.getIsSorted() ? (
header.column.getIsSorted() === 'desc' ? (
<TriangleDownIcon aria-label="sorted descending" />
) : (
<TriangleUpIcon aria-label="sorted ascending" />
)
) : null}
</chakra.span>
</Th>
);
})}
</Tr>
))}
</Thead>
<Tbody>
{table.getRowModel().rows.map((row) => (
<Tr key={row.id}>
{row.getVisibleCells().map((cell) => {
// see https://tanstack.com/table/v8/docs/api/core/column-def#meta to type this correctly
const meta: any = cell.column.columnDef.meta;
return (
<Td
key={cell.id}
isNumeric={meta?.isNumeric}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
);
}

View file

@ -0,0 +1,62 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogCloseButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
useColorModeValue
} from '@chakra-ui/react';
export default function DeleteDialog({
isOpen,
onClose,
cancelRef,
onDelete,
title,
body,
buttonText = 'Delete'
}: any) {
return (
<>
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent bg={useColorModeValue('white', 'gray.800')}>
<AlertDialogHeader
fontSize="lg"
fontWeight="bold"
pb={2}
>
{title ? title : 'Delete item'}
</AlertDialogHeader>
<AlertDialogBody>{body ? body : "Are you sure? You can't undo this action afterwards."}</AlertDialogBody>
<AlertDialogFooter>
<Button
ref={cancelRef}
onClick={onClose}
>
Cancel
</Button>
<Button
colorScheme={'red'}
onClick={onDelete}
ml={3}
>
{buttonText}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,100 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
FormControl,
FormHelperText,
FormLabel,
Image,
Input,
Text,
useColorModeValue
} from '@chakra-ui/react';
import { useRef, useState } from 'react';
function DeleteButtonIcon() {
return <>
<Image
src={'/images/7534-dababy.png'}
alt={'Delete'}
width={6}
height={6}
/>
</>
}
export default function DeleteDialogOrganization({
isOpen,
onClose,
cancelRef,
onDelete,
buttonText = 'I Understand, Delete Organization',
organization
}: any) {
const inputRef = useRef<HTMLInputElement>(null);
const [disabled, setDisabled] = useState(true);
return (
<>
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
size={'lg'}
>
<AlertDialogOverlay>
<AlertDialogContent bg={useColorModeValue('white', 'gray.800')}>
<AlertDialogHeader
fontSize="lg"
fontWeight="bold"
pb={2}
>
Delete Organization
</AlertDialogHeader>
<AlertDialogBody>
Are you sure you want to delete this organization? This will remove all associated data, including locations, access points, access groups, and API keys.<br /><Text as={'strong'}>This action cannot be undone.</Text>
<FormControl my={4}>
<FormLabel>Organization Name</FormLabel>
<Input
placeholder={`Type "${organization?.name}" to confirm`}
ref={inputRef}
onChange={(e) => {
setDisabled(e.target.value !== organization?.name);
}}
/>
<FormHelperText>
Type &quot;{organization?.name}&quot; to confirm
</FormHelperText>
</FormControl>
</AlertDialogBody>
<AlertDialogFooter>
<Button
ref={cancelRef}
onClick={onClose}
>
Cancel
</Button>
<Button
colorScheme={'red'}
onClick={onDelete}
ml={3}
isDisabled={disabled}
>
{buttonText}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}

64
src/components/Error.tsx Normal file
View file

@ -0,0 +1,64 @@
import { Link } from '@chakra-ui/next-js';
import { Box, Button, Container, Flex, Heading, Text } from '@chakra-ui/react';
import Head from 'next/head';
import { useMemo } from 'react';
export default function Error({ statusCode }: { statusCode: string }) {
const ErrorPageMessages = {
"404": {
title: 'Page Not Found',
message: 'The page you are looking for does not exist.',
},
"403": {
title: 'Unauthorized',
message: 'You do not have permission to view this page.',
},
"401": {
title: 'Unauthorized',
message: 'You do not have permission to view this page.',
},
"500": {
title: 'Server Error',
message: 'An error occurred on the server.',
},
} as { [key: string]: ErrorPageMessage };
interface ErrorPageMessage {
title: string;
message: string;
}
const { title, message }: ErrorPageMessage = useMemo(() => {
return ErrorPageMessages[statusCode] || ErrorPageMessages['500'];
}, [statusCode]);
return (
<>
<Head>
<title>{title} - Restrafes XCS</title>
</Head>
<Container
maxW={'container.sm'}
py={16}
>
<Text
as={'h1'}
fontSize={'4xl'}
fontWeight={'900'}
>
{title}
</Text>
<Text
fontSize={'lg'}
mb={4}
>
{message}
</Text>
<Flex flexDir={'row'} gap={2}>
<Button as={Link} href={'/'} _hover={{ textDecoration: 'none' }}>Return to Home</Button>
<Button as={Link} href={'/home'} _hover={{ textDecoration: 'none' }}>Return to Platform Home</Button>
</Flex>
</Container>
</>
);
}

69
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,69 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Divider, Flex, Icon, Link, Text, useColorModeValue } from '@chakra-ui/react';
import NextLink from 'next/link';
import { BiGitBranch } from 'react-icons/bi';
export default function Footer({ type = 'platform' }: { type?: 'public' | 'platform' }) {
return (
<>
<Flex
as="footer"
position={'sticky'}
top={0}
flexDir={'column'}
w={'100%'}
h={'8rem'}
border={'1px solid'}
borderLeft={{ base: '1px solid', md: 'unset' }}
borderColor={useColorModeValue('gray.300', 'gray.700')}
// borderColor={type === 'public' ? useColorModeValue('blackAlpha.900', 'whiteAlpha.900') : useColorModeValue('gray.300', 'gray.700')}
p={4}
align={'center'}
justify={'center'}
>
<Text>
<Text
as={'span'}
fontWeight={'bold'}
letterSpacing={'tight'}
>
© RESTRAFES & CO LLC.
</Text>{' '}
All rights reserved.
</Text>
<Flex align={'center'} justify={'center'} fontSize={'sm'}>
<Link
as={NextLink}
href={'/legal/terms'}
>
Terms of Use
</Link>
<Divider
orientation={'vertical'}
mx={2}
h={'1rem'}
borderColor={useColorModeValue('gray.300', 'gray.700')}
/>
<Link
as={NextLink}
href={'/legal/privacy'}
>
Privacy Policy
</Link>
</Flex>
<Flex
color={'gray.500'}
fontSize={"sm"}
align={'center'}
>
<Icon as={BiGitBranch} mr={1} />{" "}
<Text as={'span'}>
{process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || 'dev-mode'}{" "}
({process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF || 'dev'})
</Text>
</Flex>
</Flex>
</>
);
}

212
src/components/Home.tsx Normal file
View file

@ -0,0 +1,212 @@
import { Box, Button, Container, Flex, Heading, Icon, Image, Spacer, Text, chakra, useColorModeValue } from '@chakra-ui/react';
import moment from 'moment';
import NextImage from 'next/image';
import Link from 'next/link';
import Marquee from 'react-fast-marquee';
import { BsArrowRight } from 'react-icons/bs';
import Section from './section';
const ChakraImage = chakra(NextImage, {
baseStyle: { maxH: 120, maxW: 120 },
shouldForwardProp: (prop) => ['width', 'height', 'src', 'alt'].includes(prop),
});
const ChakraMarquee = chakra(Marquee, {
baseStyle: {
height: '100%',
width: '100%',
},
});
export default function Home({ allPostsData: posts }: { allPostsData: any }) {
return (
// New Bold Typography Design
<>
<Container
as={Flex}
flexDir={'column'}
maxW={'100%'}
position={'relative'}
p={0}
pt={16}
align={'center'}
>
<Flex
flexDir={'column'}
position={'relative'}
maxW={'container.xl'}
h={'calc(100dvh)'}
borderBottom={'1px solid'}
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
mx={{ base: 4, md: 16 }}
pb={24}
>
<Section>
<Heading as={'h1'} size={{ base: 'xl', md: '4xl' }} fontWeight={'normal'} pb={{ base: 8, md: 16 }} w={{ base: 'full', md: '66%' }}>
Powering the future of access control.
</Heading>
</Section>
<Flex
flexBasis={1}
flexGrow={2}
overflow={'hidden'}
w={'full'}
>
<Image
src={'/images/home-hero.jpg'}
alt={'Home Image'}
w={"full"}
height={"full"}
style={{
objectFit: 'cover',
}}
/>
</Flex>
</Flex>
{/* <Flex
flexDir={'column'}
minH={'50vh'}
w={'container.xl'}
mx={{ base: 4, md: 16 }}
py={16}
borderBottom={'1px solid'}
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
>
<Box
flexGrow={1}
flexBasis={1}
pb={8}
display={{ base: 'none', md: 'block' }}
>
<Heading
size={'xl'}
fontWeight={'400'}
pb={2}
>
Blog Posts
</Heading>
<Spacer />
</Box>
<Box
flexGrow={1}
flexBasis={1}
>
<Flex flexDir={'column'} gap={4} w={'540px'}>
<Link href={`/blog/${posts[0].id}`}>
<Image src={posts[0].thumbnail} alt={posts[0].thumbnailAlt} objectFit={'cover'} aspectRatio={2 / 1} />
</Link>
<Flex flexDir={'column'}>
<Heading size={'md'}>
{posts[0].title}
</Heading>
<Text>
{moment(posts[0].date).format('MMMM Do, YYYY')}
</Text>
</Flex>
</Flex>
</Box>
</Flex> */}
<Flex
minH={'50vh'}
maxW={'container.xl'}
mx={{ base: 4, md: 16 }}
py={16}
borderBottom={'1px solid'}
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
>
<Box
flexGrow={1}
flexBasis={1}
display={{ base: 'none', md: 'block' }}
>
<Heading
size={'xl'}
fontWeight={'400'}
pb={2}
>
Qu&apos;est-ce que Restrafes XCS?
</Heading>
</Box>
<Box
flexGrow={1}
flexBasis={1}
>
<Heading
size={'xl'}
fontWeight={'400'}
pb={2}
>
What is Restrafes XCS?
</Heading>
<Text fontSize={'xl'}>
Restrafes XCS is a powerful access control system designed to help you manage access points for your
building. With Restrafes XCS, you can easily and securely control who has access to your property, including
employees and visitors. The system is highly customizable, allowing you to set access levels and permissions
for different users, and offers a range of advanced features such as real-time monitoring, reporting, and
reverse-compatibility with other systems. Whether you&apos;re looking to enhance the security of your
business or residential property, Restrafes XCS provides the flexibility and reliability you need
to manage access with confidence.
</Text>
</Box>
</Flex>
<Flex
flexDir={{ base: 'column', md: 'row' }}
w={'100%'}
maxW={'container.xl'}
mx={{ base: 4, md: 16 }}
justify={{ base: 'center', md: 'space-between' }}
gap={4}
align={'center'}
py={24}
>
<Box>
<Heading
size={'2xl'}
fontWeight={'normal'}
>
Get started today.
</Heading>
</Box>
<Box>
<Text
fontSize={'xl'}
maxW={'24ch'}
>
Open access coming soon. Available now for beta testers.
</Text>
</Box>
<Box
my={4}
>
<Button
as={Link}
h={'auto'}
// variant={pathname === href ? 'solid' : variant}
variant={'unstyled'}
border={'1px solid'}
borderColor={useColorModeValue('blackAlpha.900', 'white')}
borderRadius={'none'}
py={2}
px={4}
href={'/auth/login'}
transition={'all 0.2s ease'}
_hover={{
bg: useColorModeValue('blackAlpha.900', 'white'),
color: useColorModeValue('white', 'black')
}}
_active={{
bg: useColorModeValue('blackAlpha.700', 'white'),
color: useColorModeValue('white', 'black')
}}
lineHeight={1.25}
>
Access Platform
<Icon as={BsArrowRight} ml={1} h={3} />
</Button>
</Box>
</Flex>
</Container>
</>
);
}

View file

@ -0,0 +1,5 @@
export default function InspectScanModal() {
return <>
</>
}

View file

@ -0,0 +1,300 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
Container,
Flex,
Icon,
Image,
Skeleton,
Text,
chakra,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Link } from '@chakra-ui/next-js';
import { useAuthContext } from '@/contexts/AuthContext';
import { Invitation } from '@/types';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { FiArrowRight } from 'react-icons/fi';
export default function Invitation({ invite, errorMessage }: { invite: Invitation, errorMessage: string | null }) {
const { query, push } = useRouter();
const toast = useToast();
const [isAcceptLoading, setIsAcceptLoading] = useState<boolean>(false);
const { user, currentUser } = useAuthContext();
const [loading, setLoading] = useState<boolean>(true);
let { id: queryId } = query;
const id = queryId?.length ? queryId[0] : null;
useEffect(() => {
setLoading(false);
}, [user]);
const acceptInvite = async () => {
setIsAcceptLoading(true);
if (invite.type === 'organization') {
push(`/organizations/?invitation=${query.id}`);
} else if (invite.type === 'xcs') {
push(`/auth/activate/${query.id}`);
}
};
const inviteTypeSwitch = (type: string) => {
switch (type) {
case 'organization':
return 'join their organization';
case 'xcs':
return 'create an account.';
default:
return null;
}
};
return (
<>
<Head>
<title>Invitation - Restrafes XCS</title>
</Head>
<Container
maxW={'container.lg'}
h={'100dvh'}
>
<Flex
pos={'relative'}
flexDir={'column'}
align={'center'}
justify={'center'}
h={'full'}
bottom={[0, 8]}
>
<Link
my={8}
href={'/'}
>
<Image
src={useColorModeValue('/images/logo-black.png', '/images/logo-white.png')}
alt={'Restrafes XCS Logo'}
w={'auto'}
h={'24px'}
objectFit={'contain'}
transition={'filter 0.2s ease'}
_hover={{
filter: useColorModeValue('opacity(0.75)', 'brightness(0.75)')
}}
_active={{
filter: useColorModeValue('opacity(0.5)', 'brightness(0.5)')
}}
/>
</Link>
<Flex
maxW={['100%', 'lg']}
// aspectRatio={invite ? 1 / 1.25 : 'unset'}
minH={invite ? 'xl' : 'unset'}
rounded={'lg'}
border={['none', '1px solid']}
borderColor={['none', useColorModeValue('gray.300', 'gray.600')]}
direction={'column'}
align={'center'}
justify={'space-between'}
p={[4, 8]}
>
<Box w={'full'}>
<Skeleton isLoaded={!loading}>
<Text
as={'h2'}
fontSize={{ base: '2xl', sm: '3xl' }}
fontWeight={'900'}
letterSpacing={'tight'}
w={'full'}
textAlign={'center'}
>
{invite
? invite.type === 'organization'
? "You've recieved an invitation"
: "You're invited to register"
: 'Invitation not found'}
</Text>
</Skeleton>
<Skeleton isLoaded={!loading}>
<Text
fontSize={'lg'}
mb={2}
textAlign={'center'}
>
{invite ? (
<>
{invite?.creator?.displayName || invite?.creator?.name?.first} has invited you to{' '}
{inviteTypeSwitch(invite?.type)}
{invite?.type === 'organization' ? (
<Text
as={'span'}
fontWeight={'bold'}
>
{', '}
{invite.organization?.name}
</Text>
) : null}
</>
) : (
<>{!errorMessage ? "The invitation you are looking for is either invalid or no longer exists." : errorMessage}</>
)}
</Text>
</Skeleton>
</Box>
{invite ? (
<>
<Flex
flexDir={'row'}
align={'center'}
justify={'center'}
flexGrow={1}
w={'full'}
p={4}
>
<Skeleton
display={'flex'}
isLoaded={!loading}
objectFit={'contain'}
justifyContent={'center'}
rounded={'full'}
>
<Avatar
src={invite?.creator?.avatar || '/images/default-avatar.png'}
size={'full'}
maxW={'240px'}
aspectRatio={1 / 1}
outline={'1px solid'}
outlineColor={useColorModeValue('gray.300', 'gray.600')}
/>
</Skeleton>
{
invite.type === 'organization' && (
<>
<Icon as={FiArrowRight} fontSize={'3xl'} mx={4} />
<Skeleton
display={'flex'}
isLoaded={!loading}
objectFit={'contain'}
justifyContent={'center'}
rounded={'full'}
>
<Avatar
src={invite?.organization?.avatar || '/images/default-avatar-organization.png'}
size={'full'}
maxW={'240px'}
aspectRatio={1 / 1}
outline={'1px solid'}
outlineColor={useColorModeValue('gray.300', 'gray.600')}
borderRadius={'lg'}
/>
</Skeleton>
</>
)
}
</Flex>
<Box w={'full'}>
<Skeleton isLoaded={!loading}>
{currentUser || invite.type === 'xcs' ? (
<Button
w={'full'}
my={2}
isLoading={isAcceptLoading}
onClick={acceptInvite}
isDisabled={invite?.type === 'xcs' && currentUser}
>
{invite?.type === 'xcs'
? currentUser
? 'You are logged in'
: 'Register & accept'
: 'Accept invitation'}
</Button>
) : (
<Button
w={'full'}
my={2}
isLoading={isAcceptLoading}
onClick={() => {
setIsAcceptLoading(true);
push('/login?redirect=/invitation/' + query.id);
}}
>
Login to accept
</Button>
)}
</Skeleton>
<Skeleton isLoaded={!loading}>
<Text
fontSize={'sm'}
my={2}
textAlign={'center'}
>
By accepting this invitation, you agree to the{' '}
<chakra.div as={'span'} whiteSpace={'nowrap'}>
<Text
as={'span'}
fontWeight={'bold'}
>
Restrafes XCS
</Text>{' '}
<Text
as={'span'}
fontWeight={'bold'}
whiteSpace={'nowrap'}
>
<Link
href={'/legal/terms'}
textDecor={'underline'}
textUnderlineOffset={4}
>
Terms of Use
</Link>
</Text>{' '}
and{' '}
<Text
as={'span'}
fontWeight={'bold'}
whiteSpace={'nowrap'}
>
<Link
href={'/legal/privacy'}
textDecor={'underline'}
textUnderlineOffset={4}
>
Privacy Policy
</Link>
.
</Text>
</chakra.div>
</Text>
</Skeleton>
</Box>
</>
) : (
<>
<Box w={'full'}>
<Button
as={Link}
href={'/'}
w={'full'}
mt={4}
>
Return to Home
</Button>
</Box>
</>
)}
</Flex>
</Flex>
</Container>
</>
);
}

View file

@ -0,0 +1,887 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
Flex,
FormControl,
FormHelperText,
FormLabel,
Icon,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
RadioGroup,
Spacer,
Stack,
Step,
StepIcon,
StepIndicator,
StepSeparator,
StepStatus,
Stepper,
Text,
chakra,
useColorModeValue,
useRadio,
useRadioGroup,
useSteps,
useToast
} from '@chakra-ui/react';
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
import { AiFillIdcard } from 'react-icons/ai';
import { FaIdBadge } from 'react-icons/fa';
import { SiRoblox, SiRobloxstudio } from 'react-icons/si';
const steps = [
{ description: 'Choose a Member Type' },
{ description: 'Enter Member Details' },
{ description: 'Completed' },
]
interface SelectOption {
label?: string;
value: string | number;
}
function RadioCard(props: any) {
const { getInputProps, getRadioProps } = useRadio(props as any)
const input = getInputProps()
const checkbox = getRadioProps()
return (
<Box as='label'>
<input {...input} />
<Box
{...checkbox}
cursor='pointer'
borderWidth='1px'
borderRadius='lg'
borderColor={useColorModeValue('gray.200', 'gray.700')}
transition={'background 0.2s ease-out'}
_hover={{
bg: useColorModeValue('gray.50', 'gray.700'),
}}
_checked={{
bg: useColorModeValue('gray.100', 'gray.700'),
}}
_active={{
bg: useColorModeValue('gray.200', 'gray.600'),
}}
// _focus={{
// boxShadow: 'outline',
// }}
px={5}
py={3}
>
{props.children}
</Box>
</Box>
)
}
export default function InviteOrganizationFlowModal({
isOpen,
onClose,
onAdd,
organization,
accessGroupOptions
}: {
isOpen: boolean;
onClose: () => void;
onAdd: () => void;
organization: any;
accessGroupOptions: any;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const formRef = useRef(null) as any;
const [groupRoles, setGroupRoles] = useState<any>([]);
const [lastGroupId, setLastGroupId] = useState<any>('');
const groupIdRef = useRef<any>(null);
const { user } = useAuthContext();
const { activeStep, setActiveStep } = useSteps({
index: 0,
count: steps.length,
})
const activeStepText = steps[activeStep].description
const memberTypeOptions = [
{
icon: FaIdBadge,
value: 'user',
label: 'User',
description: 'A registered user on the XCS platform.'
},
{
icon: SiRoblox,
value: 'roblox',
label: 'Roblox User',
description: 'A Roblox user.'
},
{
icon: SiRobloxstudio,
value: 'roblox-group',
label: 'Roblox Group',
description: 'A Roblox group with roles (member, staff, etc.)'
},
{
icon: AiFillIdcard,
value: 'card',
label: 'Card Numbers',
description: 'A set of card numbers.'
}
]
useEffect(() => {
if (activeStep === 0) {
// reset form
const preserveType = formRef.current.values.type;
formRef.current.resetForm();
formRef.current.setFieldValue('type', preserveType);
}
}, [activeStep])
const { setValue: setRadioMemberType, getRadioProps: getRadioPropsMemberType } = useRadioGroup({
name: 'memberType',
defaultValue: 'user',
onChange: (value) => {
if (!formRef.current) return;
formRef.current.setFieldValue('type', value)
}
})
const getUserSearchResults = useCallback(async (inputValue: string, callback: any) => {
if (!inputValue) {
callback([]);
return;
}
await user.getIdToken().then((token: any) => {
fetch(`/api/v1/platform/search-users/${encodeURIComponent(inputValue)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
callback(
data.map((user: any) => ({
label: `${user.displayName} (${user.username})`,
value: user.id
}))
);
})
.catch((error) => {
// toast({
// title: 'There was an error searching for users.',
// description: error.message,
// status: 'error',
// duration: 5000,
// isClosable: true
// });
});
});
}, [user]);
const getGroupSearchResults = useCallback((value: string, callback: any) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/roblox/group-search/${encodeURIComponent(value)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
let options = [] as any;
data.forEach((group: any) => {
options.push({
label: group.name,
value: group.id
});
});
callback(options);
})
.catch((error) => {
toast({
title: 'There was an error fetching Roblox group search results.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
callback([]);
});
});
}, [user, toast]);
const fetchGroupRoles = useCallback((groupId: any) => {
groupId = groupId?.value;
setGroupRoles([]);
if (!groupId) return;
user.getIdToken().then((token: any) => {
fetch(`/api/v1/roblox/group-roles/${groupId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
setGroupRoles(
data.map((role: any) => {
return {
label: role.name,
value: role.id
};
})
);
if (lastGroupId !== groupId) {
formRef.current.setFieldValue('robloxGroupRoles', []);
}
toast({
title: 'Successfully fetched group roles.',
status: 'success',
duration: 5000,
isClosable: true
});
setLastGroupId(groupId);
})
.catch((error) => {
toast({
title: 'There was an error fetching Roblox group roles.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
});
});
}, [user, toast, lastGroupId]);
return (
<>
<Formik
innerRef={formRef}
enableReinitialize={true}
initialValues={{
type: 'user',
name: '',
role: { label: 'Member', value: 1 } as SelectOption,
id: null as
| SelectOption
| any,
username: '',
accessGroups: [],
cardNumbers: [] as SelectOption[],
robloxGroupId: null as any,
robloxGroupRoles: []
}}
onSubmit={(values, actions) => {
let finalBody = {} as any;
switch (values.type) {
case 'user':
finalBody = {
type: values.type,
id: values.id?.value,
role: values.role?.value,
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
};
break;
case 'roblox':
finalBody = {
type: values.type,
username: values.username,
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
};
break;
case 'roblox-group':
finalBody = {
type: values.type,
name: values.name,
robloxGroupId: values.robloxGroupId?.value,
robloxGroupRoles: values.robloxGroupRoles.map((r: any) => r.value),
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
};
break;
case 'card':
finalBody = {
type: values.type,
name: values.name,
cardNumbers: values?.cardNumbers?.map((cn: any) => cn?.value),
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
};
break;
}
user.getIdToken().then((token: any) => {
fetch(`/api/v1/organizations/${organization.id}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(finalBody)
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
onClose();
onAdd();
// actions.resetForm();
// setActiveStep(0);
// setRadioMemberType('user');
})
.catch((error) => {
toast({
title: 'There was an error adding a member to your organization.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
allowPinchZoom
closeOnOverlayClick={false}
onCloseComplete={() => {
props.resetForm();
setActiveStep(0);
setRadioMemberType('user');
}}
size={'lg'}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader>
Add Member
</ModalHeader>
<ModalCloseButton />
<ModalBody minH={'xl'}>
<Stack gap={0} py={2}>
<Stepper size='sm' index={activeStep} gap='0' colorScheme='black'>
{steps.map((step, index) => (
<Step as={chakra.div} key={index} gap={0}>
<StepIndicator>
<StepStatus complete={<StepIcon />} />
</StepIndicator>
<StepSeparator as={chakra.div} _horizontal={{ ml: '0' }} />
</Step>
))}
</Stepper>
<Text fontSize={'xl'} pt={4}>
Step {activeStep + 1}: <b>{activeStepText}</b>
</Text>
<Text variant={'subtext'} fontSize={'sm'}>
{
activeStep === 0 && (
'Choose the type of member you want to add to your organization.'
)
}
{
activeStep === 1 && (
'Enter the details for the member you want to add to your organization.'
)
}
{
activeStep === 2 && (
'Review your member details before adding them to your organization.'
)
}
</Text>
</Stack>
<Flex py={4} w={'full'}>
{
activeStep === 0 && (
<Field name="type">
{({ field, form }: any) => (
<Flex flexDir={'row'} gap={4} w={'full'}>
<RadioGroup {...field} defaultValue="user" dir='column' value={field.value} w={'full'}>
<Stack spacing={2}>
{memberTypeOptions.map((value: any) => {
const radio = getRadioPropsMemberType({ value: value.value })
return (
<RadioCard key={value.value} {...radio}>
<Flex flexDir={'row'} align={'center'} justify={'flex-start'}>
<Icon as={value.icon} w={5} h={5} />
<Flex flexDir={'column'} ml={4}>
<Text fontWeight={'bold'}>
{value.label}
</Text>
<Text fontSize={'sm'} color={'gray.500'}>
{value.description}
</Text>
</Flex>
</Flex>
</RadioCard>
)
})}
</Stack>
</RadioGroup>
</Flex>
)}
</Field>
)
}
{
activeStep === 1 && (
<Flex flexDir={'column'} gap={2} w={'full'}>
{
props.values.type && (
<>
{props.values.type === 'user' &&
<Field name="id">
{({ field, form }: any) => (
<FormControl minW={'196px'} w={'fit-content'}>
<FormLabel>User</FormLabel>
<AsyncSelect
{...field}
name="User"
options={[]}
placeholder="Search for a user..."
isMulti={false}
closeMenuOnSelect={true}
isClearable={true}
size="md"
noOptionsMessage={() => 'No search results found.'}
loadOptions={(inputValue, callback) => {
getUserSearchResults(inputValue, callback);
}}
onChange={(value) => {
form.setFieldValue('id', value);
}}
value={field.value || []}
/>
<FormHelperText>
Enter the user you want to add to your organization.
</FormHelperText>
</FormControl>
)}
</Field>}
{props.values.type === 'roblox' &&
<Field name="username">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Username</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Roblox Username'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
</FormControl>
)}
</Field>
}
{
props.values.type === 'roblox-group' &&
<>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Name'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
<FormHelperText>
Enter the name you want to give to the group that&apos;s easy to distinguish.
</FormHelperText>
</FormControl>
)}
</Field>
<Stack
direction={{ base: 'column', md: 'column' }}
w={'full'}
>
<Field name="robloxGroupId">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Group</FormLabel>
<AsyncSelect
{...field}
name="robloxGroupId"
ref={groupIdRef}
options={[]}
placeholder="Search for a group..."
isMulti={false}
closeMenuOnSelect={true}
isClearable={true}
size="md"
noOptionsMessage={() => 'No search results found.'}
loadOptions={(inputValue, callback) => {
getGroupSearchResults(inputValue, callback);
}}
onChange={(value) => {
form.setFieldValue('robloxGroupId', value);
fetchGroupRoles(value);
}}
value={field.value || []}
/>
</FormControl>
)}
</Field>
<Field name="robloxGroupRoles">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Group Roles</FormLabel>
<Select
{...field}
variant={'outline'}
options={groupRoles}
onChange={(value) => {
form.setFieldValue('robloxGroupRoles', value);
}}
value={field.value || []}
placeholder="Select group roles..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle="check"
/>
</FormControl>
)}
</Field>
</Stack>
</>
}
{
props.values.type === 'card' && (
<>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Name'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
<FormHelperText>
Enter the name you want to give to the group that&apos;s easy to distinguish.
</FormHelperText>
</FormControl>
)}
</Field>
<Field name="cardNumbers">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Card Numbers</FormLabel>
<CreatableSelect
{...field}
variant={'outline'}
options={[]}
onChange={(value) => {
form.setFieldValue('cardNumbers', value);
}}
value={field.value || []}
placeholder="Enter card numbers..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle="check"
/>
<FormHelperText>
Enter the card numbers you want to add to your organization. <Text as={'strong'}>Ranges are supported (e.g. 1-24).</Text>
</FormHelperText>
</FormControl>
)}
</Field>
</>
)
}
{
props.values.type === 'user' && (
<Field name="role">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Organization Role</FormLabel>
<Select
{...field}
variant={'outline'}
options={[
{ label: 'Member', value: 1 },
{ label: 'Manager', value: 2 }
]}
onChange={(value) => {
form.setFieldValue('role', value);
}}
value={field.value}
placeholder="Select a role..."
single={true}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
isDisabled={props.values.type !== 'user'}
/>
<FormHelperText>
{
props.values.type === 'user' ? (
'Select the role you want to give to the user.'
) : (
'You cannot select a role for this member type.'
)
}
</FormHelperText>
</FormControl>
)}
</Field>
)}
<Field name="accessGroups">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Access Groups</FormLabel>
<Select
{...field}
variant={'outline'}
options={accessGroupOptions}
onChange={(value) => {
form.setFieldValue('accessGroups', value);
}}
value={field.value || []}
placeholder="Select an access group..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
/>
<FormHelperText>
Select the access groups you want to give to this member type.
</FormHelperText>
</FormControl>
)}
</Field>
</>
)
}
</Flex>
)
}
{/* {
activeStep === 2 && (
<Flex flexDir={'column'} p={4} w={'full'} border={'1px solid'} borderColor={useColorModeValue('gray.200', 'gray.700')} borderRadius={'lg'}>
<Heading as={'h3'} fontSize={'xl'} fontWeight={'bold'} pb={2}>
Member Type
</Heading>
<Flex flexDir={'row'} gap={2} align={'center'}>
<Icon as={memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.icon} w={5} h={5} />
<Flex flexDir={'column'} ml={3}>
<Text fontWeight={'bold'}>
{memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.label}
</Text>
<Text fontSize={'sm'} color={'gray.500'}>
{memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.description}
</Text>
</Flex>
</Flex>
<Heading as={'h3'} fontSize={'xl'} fontWeight={'bold'} pt={4} pb={2}>
Member Details
</Heading>
{
props.values.type === 'user' && (
<>
<Flex flexDir={'row'} gap={2} align={'center'}>
<Icon as={FaIdBadge} w={5} h={5} />
<Flex flexDir={'column'} ml={3}>
<Text fontWeight={'bold'}>
{props.values.id?.label}
</Text>
<Text fontSize={'sm'} color={'gray.500'}>
{props.values.id?.value}
</Text>
</Flex>
</Flex>
<Flex flexDir={'row'} gap={2} align={'center'} pt={2}>
<Icon as={AiFillIdcard} w={5} h={5} />
<Flex flexDir={'column'} ml={3}>
<Text fontWeight={'bold'}>
{props.values.role?.label}
</Text>
<Text fontSize={'sm'} color={'gray.500'}>
{props.values.role?.value}
</Text>
</Flex>
</Flex>
</>
)
}
{
props.values.type === 'roblox' && (
<>
<Flex flexDir={'row'} gap={2} align={'center'}>
<Flex flexDir={'column'}>
<Text variant={'subtext'}>Roblox Username</Text>
<Text fontWeight={'bold'}>
{props.values.username}
</Text>
</Flex>
</Flex>
</>
)
}
</Flex>
)
} */}
</Flex>
{/* <VStack spacing={2}> */}
{/* <Field name="username">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Username</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Roblox Username'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
</FormControl>
)}
</Field>
<Field name="accessGroups">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Access Groups</FormLabel>
<Select
{...field}
variant={'outline'}
options={accessGroupOptions}
onChange={(value) => {
form.setFieldValue('accessGroups', value);
}}
value={field.value || []}
placeholder="Select an access group..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
/>
<FormHelperText>
Add a Roblox user that isn&apos;t registered on XCS to your organization.
</FormHelperText>
</FormControl>
)}
</Field> */}
{/* </VStack> */}
</ModalBody>
<ModalFooter gap={4}>
<Button
onClick={() => { setActiveStep(activeStep - 1) }}
isDisabled={activeStep === 0}
>
Previous
</Button>
<Spacer />
{activeStep === 0 ? (
<Button
onClick={() => { setActiveStep(activeStep + 1) }}
isDisabled={activeStep === steps.length - 1}
colorScheme='black'
>
Next
</Button>
) : (
<Button
type="submit"
isLoading={props.isSubmitting}
colorScheme={'black'}
isDisabled={
(props.values.type === 'user' && (!props.values.id || !props.values.role)) ||
(props.values.type === 'roblox' && !props.values.username) ||
(props.values.type === 'roblox-group' && (!props.values.name || !props.values.robloxGroupId || !props.values.robloxGroupRoles)) ||
(props.values.type === 'card' && (!props.values.name || !props.values.cardNumbers))
}
>
{(props.values.type === 'user') ? "Invite" : "Add"} Member
</Button>
)}
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik >
</>
);
}

View file

@ -0,0 +1,227 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from 'react';
import {
Button,
Checkbox,
FormControl,
FormHelperText,
FormLabel,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
VStack,
useClipboard,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Select } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
export default function InviteOrganizationModal({
isOpen,
onOpen,
onClose,
onCreate,
organizationId
}: {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onCreate: (location: any) => void;
organizationId?: string;
}) {
const toast = useToast();
const { user } = useAuthContext();
const [inviteCode, setInviteCode] = useState<string | null>(null);
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
const onModalClose = () => {
onClose();
setInviteCode(null);
};
const copyInviteLink = () => {
onClipboardCopy();
toast({
title: 'Copied invite link to clipboard!',
status: 'success',
duration: 5000,
isClosable: true
});
};
return (
<>
<Formik
enableReinitialize={true}
initialValues={{ role: { label: 'Member', value: 1 }, singleUse: true }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/organizations/${organizationId}/invitations/links`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
singleUse: values.singleUse,
role: values.role.value
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
setInviteCode(data.inviteCode);
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.inviteCode}`);
actions.resetForm();
onCreate(data.inviteCode);
// onClose();
})
.catch((error) => {
toast({
title: 'There was an error creating the invitation.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onModalClose}
isCentered
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Create Invitation Link</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
{!inviteCode ? (
<>
<Field name="role">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Role</FormLabel>
<Select
{...field}
variant={'outline'}
options={[
{ label: 'Member', value: 1 },
{ label: 'Manager', value: 2 }
]}
onChange={(value) => {
form.setFieldValue('role', value);
}}
value={field.value}
placeholder="Select a role..."
single={true}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
/>
</FormControl>
)}
</Field>
<Field
name="singleUse"
type={'checkbox'}
>
{({ field, form }: any) => (
<FormControl>
<Checkbox
{...field}
variant={'outline'}
isChecked={field.value}
>
Single Use
</Checkbox>
<FormHelperText>
All invitation links expire after 14 days.
</FormHelperText>
</FormControl>
)}
</Field>
</>
) : (
<>
<FormControl>
<FormLabel>Invitation Link</FormLabel>
<Input
variant={'outline'}
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
isReadOnly={true}
onFocus={(e) => e.target.select()}
/>
<FormHelperText>
Share this link with the person you want to invite to your organization.
</FormHelperText>
</FormControl>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
{!inviteCode ? (
<HStack>
<Button
colorScheme={'black'}
isLoading={props.isSubmitting}
type={'submit'}
>
Create Link
</Button>
<Button onClick={onModalClose}>Cancel</Button>
</HStack>
) : (
<HStack>
<Button
colorScheme={'black'}
onClick={copyInviteLink}
>
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
</Button>
<Button onClick={onModalClose}>Close</Button>
</HStack>
)}
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,354 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Button,
Flex,
FormControl,
FormHelperText,
FormLabel,
HStack,
IconButton,
Input,
InputGroup,
Menu,
MenuButton,
MenuDivider,
MenuGroup,
MenuItem,
MenuItemOption,
MenuList,
MenuOptionGroup,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Stack,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { AiFillCheckCircle } from 'react-icons/ai';
import { AccessGroup, Organization } from '@/types';
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
export default function InviteOrganizationRobloxGroupModal({
isOpen,
onClose,
onAdd,
organization,
accessGroupOptions
}: {
isOpen: boolean;
onClose: () => void;
onAdd: () => void;
organization: any;
accessGroupOptions: any;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
const [groupRoles, setGroupRoles] = useState<any>([]);
const [groupSearchResults, setGroupSearchResults] = useState<any>([]);
const [lastGroupId, setLastGroupId] = useState<any>('');
const groupIdRef = useRef<any>(null);
const groupRolesRef = useRef<any>(null);
const groupNameRef = useRef<any>(null);
const formRef = useRef<any>(null);
const getGroupSearchResults = (value: string, callback: any) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/roblox/group-search/${encodeURIComponent(value)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
let options = [] as any;
data.forEach((group: any) => {
options.push({
label: group.name,
value: group.id
});
});
callback(options);
})
.catch((error) => {
toast({
title: 'There was an error fetching Roblox group search results.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
callback([]);
});
});
};
const fetchGroupRoles = (groupId: any) => {
groupId = groupId?.value;
setGroupRoles([]);
if (!groupId) return;
user.getIdToken().then((token: any) => {
fetch(`/api/v1/roblox/group-roles/${groupId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
setGroupRoles(
data.map((role: any) => {
return {
label: role.name,
value: role.id
};
})
);
if (lastGroupId !== groupId) {
formRef.current.setFieldValue('robloxGroupRoles', []);
}
toast({
title: 'Successfully fetched group roles.',
status: 'success',
duration: 5000,
isClosable: true
});
setLastGroupId(groupId);
})
.catch((error) => {
toast({
title: 'There was an error fetching Roblox group roles.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
});
});
};
return (
<>
<Formik
innerRef={formRef}
enableReinitialize={true}
initialValues={{
name: '',
robloxGroupId: '' as any,
robloxGroupRoles: [],
accessGroups: []
}}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/organizations/${organization.id}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
type: 'roblox-group',
name: values.name || values.robloxGroupId?.label || 'Roblox Group',
robloxGroupId: values.robloxGroupId?.value,
robloxGroupRoles: values.robloxGroupRoles?.map((role: any) => role.value),
// get access group ids from names
accessGroups: values?.accessGroups.map((accessGroup: any) => accessGroup.value)
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
onClose();
onAdd();
actions.resetForm();
})
.catch((error) => {
toast({
title: 'There was an error adding a Roblox group to your organization.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
allowPinchZoom
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Add Roblox Group</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="name">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Name</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Name'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
</FormControl>
)}
</Field>
<Stack
direction={{ base: 'column', md: 'column' }}
w={'full'}
>
<Field name="robloxGroupId">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Group</FormLabel>
<AsyncSelect
{...field}
name="robloxGroupId"
ref={groupIdRef}
options={[]}
placeholder="Search for a group..."
isMulti={false}
closeMenuOnSelect={true}
isClearable={true}
size="md"
noOptionsMessage={() => 'No search results found.'}
loadOptions={(inputValue, callback) => {
getGroupSearchResults(inputValue, callback);
}}
onChange={(value) => {
form.setFieldValue('robloxGroupId', value);
fetchGroupRoles(value);
}}
value={field.value || []}
/>
</FormControl>
)}
</Field>
<Field name="robloxGroupRoles">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Group Roles</FormLabel>
<Select
{...field}
variant={'outline'}
options={groupRoles}
onChange={(value) => {
form.setFieldValue('robloxGroupRoles', value);
}}
value={field.value || []}
placeholder="Select group roles..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle="check"
/>
</FormControl>
)}
</Field>
</Stack>
<Field name="accessGroups">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Access Groups</FormLabel>
<Select
{...field}
variant={'outline'}
options={accessGroupOptions}
onChange={(value) => {
form.setFieldValue('accessGroups', value);
}}
value={field.value || []}
placeholder="Select an access group..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
/>
<FormHelperText>
Add a Roblox group to your organization to start managing their access.
</FormHelperText>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Add Roblox Group
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,240 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useRef } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
VStack,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { AccessGroup, Organization } from '@/types';
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import { agKV, agNames, roleToText, textToRole } from '@/lib/utils';
import { useAuthContext } from '@/contexts/AuthContext';
export default function InviteOrganizationRobloxModal({
isOpen,
onClose,
onAdd,
organization,
accessGroupOptions
}: {
isOpen: boolean;
onClose: () => void;
onAdd: () => void;
organization: any;
accessGroupOptions: any;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
const getAccessGroupType = (ag: AccessGroup) => {
if (ag.type === 'organization') {
return 'Organization';
} else if (ag.type === 'location') {
// TODO: get location name
return ag.locationName || ag.locationId || 'Unknown';
} else {
return ag.type;
}
};
const getAccessGroupOptions = useCallback(
(organization: Organization) => {
if (!organization) return [];
const ags = Object.values(organization?.accessGroups) || [];
interface Group {
label: string;
options: {
label: string;
value: string;
}[];
}
let groups = [] as any;
ags.forEach((ag: AccessGroup) => {
// check if the group is already in the groups object
if (groups.find((g: Group) => g.label === getAccessGroupType(ag))) {
// if it is, add the option to the options array
groups
.find((g: Group) => g.label === getAccessGroupType(ag))
.options.push({
label: ag.name,
value: ag.id
});
} else {
// if it's not, add the group to the groups array
groups.push({
label: getAccessGroupType(ag),
options: [
{
label: ag.name,
value: ag.id
}
]
});
}
});
// sort the groups so organizations are at the bottom
groups.sort((a: Group, b: Group) => {
if (a.label === 'Organization') return 1;
if (b.label === 'Organization') return -1;
return 0;
});
return groups;
},
[organization]
);
return (
<>
<Formik
enableReinitialize={true}
initialValues={{ username: '', accessGroups: [] }}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/organizations/${organization.id}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
type: 'roblox',
username: values.username,
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
onClose();
onAdd();
actions.resetForm();
})
.catch((error) => {
toast({
title: 'There was an error adding a Roblox user to your organization.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
allowPinchZoom
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Add Roblox Member</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
<Field name="username">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Username</FormLabel>
<Input
{...field}
type={'username'}
variant={'outline'}
placeholder={'Roblox Username'}
autoComplete={'off'}
autoCorrect={'off'}
spellCheck={'false'}
/>
</FormControl>
)}
</Field>
<Field name="accessGroups">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Access Groups</FormLabel>
<Select
{...field}
variant={'outline'}
options={accessGroupOptions}
onChange={(value) => {
form.setFieldValue('accessGroups', value);
}}
value={field.value || []}
placeholder="Select an access group..."
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
selectedOptionStyle={'check'}
/>
<FormHelperText>
Add a Roblox user that isn&apos;t registered on XCS to your organization.
</FormHelperText>
</FormControl>
)}
</Field>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Add Roblox User
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,365 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useRef, useState } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
HStack,
Input,
InputGroup,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
VStack,
useClipboard,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { AsyncSelect } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
export default function InvitePlatformModal({
isOpen,
onOpen,
onClose,
onCreate
}: {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onCreate: (location: any) => void;
}) {
const toast = useToast();
const { currentUser, user } = useAuthContext();
const [inviteCode, setInviteCode] = useState<string | null>(null);
const senderRef = useRef<any>(null);
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
const onModalClose = () => {
onClose();
setInviteCode(null);
};
const copyInviteLink = () => {
onClipboardCopy();
toast({
title: 'Copied invite link to clipboard!',
status: 'success',
duration: 5000,
isClosable: true
});
};
const getUserSearchResults = async (inputValue: string, callback: any) => {
if (!inputValue) {
callback([]);
return;
}
await user.getIdToken().then((token: any) => {
fetch(`/api/v1/admin/search-users/${encodeURIComponent(inputValue)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
callback(
data.map((user: any) => ({
label: `${user.displayName} (${user.username})`,
value: user.id
}))
);
})
.catch((error) => {
toast({
title: 'There was an error searching for users.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
});
});
};
return (
<>
<Formik
enableReinitialize={true}
initialValues={{
code: '',
senderId: null as
| {
label: string;
value: string;
}
| any,
maxUses: 1,
referrals: 0,
comment: ''
}}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/admin/invite-link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
maxUses: values.maxUses,
code: values.code,
senderId: values.senderId?.value,
referrals: values.referrals || 0,
comment: values.comment
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
setInviteCode(data.code);
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.code}`);
actions.resetForm();
onCreate(data.code);
// onClose();
})
.catch((error) => {
toast({
title: 'There was an error creating the invitation.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onModalClose}
isCentered
onCloseComplete={() => {
props.setFieldValue('code', '');
props.setFieldValue('senderId', null);
}}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Create Invitation Link</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
{!inviteCode ? (
<>
<InputGroup gap={4}>
<Field name="code">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Custom Invite Code</FormLabel>
<Input
{...field}
variant={'outline'}
autoComplete="off"
placeholder="Custom Invite Code (optional)"
/>
</FormControl>
)}
</Field>
<Field name="maxUses">
{({ field, form }: any) => (
<FormControl w={'fit-content'}>
<FormLabel>Maximum Uses</FormLabel>
<InputGroup>
<NumberInput
{...field}
autoComplete="off"
placeholder="Maximum Uses"
variant={'outline'}
min={1}
max={100}
defaultValue={1}
onChange={(value) => {
form.setFieldValue('maxUses', value);
}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</InputGroup>
</FormControl>
)}
</Field>
</InputGroup>
<Field name="senderId">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Sender</FormLabel>
<AsyncSelect
{...field}
name="senderId"
ref={senderRef}
options={[]}
placeholder="Search for a user... (optional)"
isMulti={false}
closeMenuOnSelect={true}
isClearable={true}
size="md"
noOptionsMessage={() => 'No search results found.'}
loadOptions={(inputValue, callback) => {
getUserSearchResults(inputValue, callback);
}}
onChange={(value) => {
form.setFieldValue('senderId', value);
}}
value={field.value || []}
selectedOptionStyle='check'
/>
<FormHelperText>If left blank, you will be the sender.</FormHelperText>
</FormControl>
)}
</Field>
<Field name="referrals">
{({ field, form }: any) => (
<FormControl w={'fit-content'} alignSelf={'flex-start'}>
<FormLabel>Starting Referrals</FormLabel>
<InputGroup>
<NumberInput
{...field}
autoComplete="off"
placeholder="Starting Referrals"
variant={'outline'}
min={0}
max={100}
defaultValue={0}
onChange={(value) => {
form.setFieldValue('referrals', value);
}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</InputGroup>
<FormHelperText>
If you want to give the user referrals to invite others, enter the number of referrals you want to give them here.
</FormHelperText>
</FormControl>
)}
</Field>
<Field name="comment">
{({ field, form }: any) => (
<FormControl isRequired>
<FormLabel>Comment</FormLabel>
<Input
{...field}
variant={'outline'}
autoComplete="off"
placeholder="Comment"
/>
<FormHelperText>
All invitation links expire after 14 days.
</FormHelperText>
</FormControl>
)}
</Field>
</>
) : (
<>
<FormControl>
<FormLabel>Invitation Link</FormLabel>
<Input
variant={'outline'}
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
isReadOnly={true}
onFocus={(e) => e.target.select()}
/>
<FormHelperText>
Share this link with the people you want to invite to the platform.
</FormHelperText>
</FormControl>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
{!inviteCode ? (
<HStack>
<Button
colorScheme={'black'}
isLoading={props.isSubmitting}
type={'submit'}
>
Create Link
</Button>
<Button onClick={onModalClose}>Cancel</Button>
</HStack>
) : (
<HStack>
<Button
colorScheme={'black'}
onClick={copyInviteLink}
>
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
</Button>
<Button onClick={onModalClose}>Close</Button>
</HStack>
)}
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,195 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useEffect, useRef, useState } from 'react';
import {
Button,
Checkbox,
FormControl,
FormHelperText,
FormLabel,
HStack,
Input,
InputGroup,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Stack,
Text,
Textarea,
VStack,
useClipboard,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
import { Field, Form, Formik } from 'formik';
import NextLink from 'next/link';
import { textToRole } from '@/lib/utils';
import { useAuthContext } from '@/contexts/AuthContext';
export default function InvitePlatformUserModal({
isOpen,
onOpen,
onClose,
onCreate
}: {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onCreate: (location: any) => void;
}) {
const toast = useToast();
const { currentUser, user } = useAuthContext();
const [inviteCode, setInviteCode] = useState<string | null>(null);
const senderRef = useRef<any>(null);
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
const onModalClose = () => {
onClose();
setInviteCode(null);
};
const copyInviteLink = () => {
onClipboardCopy();
toast({
title: 'Copied invite link to clipboard!',
status: 'success',
duration: 5000,
isClosable: true
});
};
return (
<>
<Formik
enableReinitialize={true}
initialValues={{}}
onSubmit={(values, actions) => {
user.getIdToken().then((token: any) => {
fetch(`/api/v1/me/referrals`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
setInviteCode(data.code);
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.code}`);
actions.resetForm();
onCreate(data.code);
// onClose();
})
.catch((error) => {
toast({
title: 'There was an error creating the invitation.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onModalClose}
isCentered
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Sponsor a User</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<VStack spacing={2}>
{!inviteCode ? (
<>
<Text>
Create an invitation link to invite people to join the platform.
</Text>
</>
) : (
<>
<FormControl>
<FormLabel>Invitation Link</FormLabel>
<Input
variant={'outline'}
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
isReadOnly={true}
onFocus={(e) => e.target.select()}
/>
<FormHelperText>
Share this link with the people you want to invite to the platform.
</FormHelperText>
</FormControl>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
{!inviteCode ? (
<HStack>
<Button
colorScheme={'black'}
isLoading={props.isSubmitting}
type={'submit'}
>
Create Link
</Button>
<Button onClick={onModalClose}>Cancel</Button>
</HStack>
) : (
<HStack>
<Button
colorScheme={'black'}
onClick={copyInviteLink}
>
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
</Button>
<Button onClick={onModalClose}>Close</Button>
</HStack>
)}
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

View file

@ -0,0 +1,143 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useRef } from 'react';
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Field, Form, Formik } from 'formik';
import { useAuthContext } from '@/contexts/AuthContext';
export default function JoinOrganizationDialog({
isOpen,
onClose,
onJoin,
initialValue = ''
}: {
isOpen: boolean;
onClose: () => void;
onJoin: (organization: any) => void;
initialValue?: string;
}) {
const toast = useToast();
const initialRef = useRef(null);
const finalRef = useRef(null);
const { user } = useAuthContext();
return (
<>
<Formik
initialValues={{ inviteCode: initialValue }}
onSubmit={async (values, actions) => {
// Handle Links
values.inviteCode = values.inviteCode.replace(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/`, '');
await user.getIdToken().then(async (token: string) => {
await fetch(`/api/v1/organizations/join`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: values.inviteCode as string
})
})
.then((res) => {
if (res.status === 200) {
return res.json();
} else {
return res.json().then((json) => {
throw new Error(json.message);
});
}
})
.then((data) => {
toast({
title: data.message,
status: 'success',
duration: 5000,
isClosable: true
});
onClose();
onJoin(data.organizationId);
})
.catch((error) => {
toast({
title: 'There was an error joining the organization.',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
});
})
.finally(() => {
actions.setSubmitting(false);
});
});
}}
>
{(props) => (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
initialFocusRef={initialRef}
>
<ModalOverlay />
<Form>
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader>Join Organization</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<Field name="inviteCode">
{({ field, form }: any) => (
<FormControl>
<FormLabel>Invite Code</FormLabel>
<Input
{...field}
variant={'outline'}
placeholder={'Invite Code'}
ref={initialRef}
/>
<FormHelperText>
Received an invite code? Enter it here to join an organization.
</FormHelperText>
</FormControl>
)}
</Field>
</ModalBody>
<ModalFooter>
<Button
colorScheme={'black'}
mr={3}
isLoading={props.isSubmitting}
type={'submit'}
>
Join
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
)}
</Formik>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,257 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Button,
ButtonGroup,
Flex,
Icon,
IconButton,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Skeleton,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tooltip,
Tr,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { Link } from '@chakra-ui/next-js';
import { useAuthContext } from '@/contexts/AuthContext';
import { Organization, OrganizationInvitation } from '@/types';
import { useRouter } from 'next/router';
import { BiRefresh } from 'react-icons/bi';
const moment = require('moment');
function TableEntry({ key, invitation, skeleton, action }: { key: number | string, invitation?: OrganizationInvitation, skeleton?: boolean, action: any }) {
const toRelativeTime = useMemo(() => (date: any) => {
return moment(new Date(date)).fromNow();
}, []);
return <>
<Tr key={key}>
<Td>
<Skeleton isLoaded={!skeleton}>
<Flex align={'center'} gap={4}>
<Avatar as={Link} href={`/@${invitation?.recipient?.username}`} target='_blank' transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }} size={'md'} src={invitation?.recipient?.avatar || '/images/default-avatar.png'} />
<Flex flexDir={'column'} justify={'center'}>
<Skeleton isLoaded={!skeleton}>
<Text fontWeight={'bold'}>
{!skeleton ? invitation?.recipient?.displayName : "N/A"}
</Text>
</Skeleton>
<Skeleton isLoaded={!skeleton}>
<Text
variant={'subtext'}
color="gray.500"
>
@{invitation?.recipient?.username}
</Text>
</Skeleton>
</Flex>
</Flex>
</Skeleton>
</Td>
<Td>
<Skeleton isLoaded={!skeleton}>
<Text>
{!skeleton ? toRelativeTime(invitation?.createdAt) : 'N/A'}
</Text>
</Skeleton>
</Td>
<Td>
<Skeleton isLoaded={!skeleton}>
<Flex align={'center'} gap={2}>
<Avatar as={Link} href={`/@${invitation?.createdBy?.username}`} target='_blank' transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }} size={'sm'} mr={2} src={invitation?.createdBy?.avatar || '/images/default-avatar.png'} />
<Flex flexDir={'column'} justify={'center'}>
<Text fontWeight={'bold'}>
{!skeleton ? invitation?.createdBy?.displayName : "N/A"}
</Text>
<Text
variant={'subtext'}
color="gray.500"
>
@{invitation?.createdBy?.username}
</Text>
</Flex>
</Flex>
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={!skeleton}>
<ButtonGroup>
<Button
onClick={() => { action(invitation, 'withdraw') }}
size={"sm"}
variant={"solid"}
colorScheme='red'
textDecor={"unset !important"}
>
Withdraw
</Button>
</ButtonGroup>
</Skeleton>
</Td>
</Tr>
</>
}
export default function OrganizationInvitationsModal({
isOpen,
onClose,
organization,
onRefresh
}: {
isOpen: boolean;
onClose: () => void;
organization?: Organization | null;
onRefresh: () => void;
}) {
const toast = useToast();
const { push } = useRouter();
const { user } = useAuthContext();
const [invitationsLoading, setInvitationsLoading] = useState(true);
const [invitations, setInvitations] = useState<OrganizationInvitation[]>([]);
const fetchInvitations = useCallback(async () => {
setInvitationsLoading(true);
user.getIdToken().then(async (token: string) => {
await fetch(`/api/v1/organizations/${organization?.id}/invitations`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}).then(async (res) => {
const data = await res.json();
if (res.status === 200) {
setInvitations(data || []);
} else {
toast({
title: "There was an error fetching the organization's invitations.",
description: data.message,
status: "error",
duration: 9000,
isClosable: true,
});
}
}).finally(() => {
setInvitationsLoading(false);
});
});
}, [user, toast, organization]);
const actOnInvitation = useCallback(async (invitation: OrganizationInvitation, action: 'withdraw') => {
setInvitationsLoading(true);
await user.getIdToken().then(async (token: string) => {
await fetch(`/api/v1/organizations/${organization?.id}/invitations/${invitation.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}).then(async (res) => {
const data = await res.json();
if (res.status === 200) {
await fetchInvitations();
toast({
title: data.message,
status: "success",
duration: 5000,
isClosable: true,
});
} else {
toast({
title: "There was an error taking action on an invitation.",
description: data.message,
status: "error",
duration: 5000,
isClosable: true,
});
}
}).finally(() => {
setInvitationsLoading(false);
});
});
}, [user, toast, fetchInvitations, organization?.id]);
useEffect(() => {
if (!user) return;
if (!organization) return;
if (!isOpen) return;
fetchInvitations();
}, [user, fetchInvitations, organization, isOpen]);
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
size={'4xl'}
>
<ModalOverlay />
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
<ModalHeader pb={2}>Invitations</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4} minH={'xl'}>
<Flex pb={4}>
<Tooltip label={'Refresh'} placement={'top'}>
<IconButton ml={'auto'} onClick={fetchInvitations} aria-label={'Refresh'} icon={<Icon as={BiRefresh} />} />
</Tooltip>
</Flex>
<TableContainer overflow={'auto'} maxH={'md'}>
<Table>
<Thead>
<Tr>
<Th>Recipient</Th>
<Th>Invite Date</Th>
<Th>Inviter</Th>
<Th isNumeric>Actions</Th>
</Tr>
</Thead>
<Tbody>
{
invitationsLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<TableEntry key={i} invitation={undefined} skeleton={true} action={actOnInvitation} />
))
) : (invitations.map((invitation: OrganizationInvitation) => (
<TableEntry key={invitation.id as string} invitation={invitation} skeleton={false} action={actOnInvitation} />
)))
}
</Tbody>
</Table>
</TableContainer>
{
!invitationsLoading && invitations.length === 0 && (
<Text py={8} w={'full'} textAlign={'center'} color={'gray.500'}>
There are no outgoing invitations.
</Text>
)
}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View file

@ -0,0 +1,20 @@
// Chakra UI
import { useColorModeValue } from '@chakra-ui/react';
// Components
import NextNProgress from 'nextjs-progressbar';
export default function PageProgress() {
return (
<NextNProgress
color={useColorModeValue('#000', '#fff')}
startPosition={0}
// stopDelayMs={500}
height={2}
options={{
showSpinner: false
}}
showOnShallow={false}
/>
);
}

View file

@ -0,0 +1,78 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
CloseButton,
Flex,
Stack,
useDisclosure
} from '@chakra-ui/react';
export default function PlatformAlert({
status = 'error',
title,
description,
isClosable = true,
button
}: {
status?: 'error' | 'info' | 'success' | 'warning';
title: string;
description?: string;
isClosable: boolean;
button?: {
isLoading?: boolean;
text: string;
onClick: () => void;
};
}) {
const { isOpen: isVisible, onClose, onOpen } = useDisclosure({ defaultIsOpen: true });
return (
isVisible && (
<>
<Alert
status={status}
backdropFilter={'blur(24px)'}
>
<AlertIcon mx={2} />
<Stack
pl={{ base: 0, md: 2 }}
direction={['column', 'row']}
align={'center'}
justify={'space-between'}
w={'full'}
>
<Box>
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Box>
<Stack
direction={'row'}
h={'full'}
>
{button && (
<Button
onClick={button.onClick}
variant={'solid'}
size={'sm'}
isLoading={button.isLoading}
>
{button.text}
</Button>
)}
{isClosable && (
<CloseButton
onClick={onClose}
position={'relative'}
/>
)}
</Stack>
</Stack>
</Alert>
</>
)
);
}

Some files were not shown because too many files have changed in this diff Show more