├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .lando.local.example.yml ├── .lando.yml ├── .npmrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── add-credential-status.test.ts ├── empty.js └── nextFontMock.js ├── index.d.ts ├── jest.config.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prettier.config.js ├── prisma └── schema │ ├── _base.prisma │ ├── achievement.prisma │ ├── alignment.prisma │ ├── auth.prisma │ ├── context.prisma │ ├── extensions.prisma │ ├── identifier.prisma │ ├── misc.prisma │ ├── multikey.prisma │ ├── profile.prisma │ ├── result.prisma │ ├── types.prisma │ └── verifiable-credential.prisma ├── public ├── CC-BY.png ├── alt-badge-engine-logo-black.svg ├── alt-badge-engine-logo-color.svg ├── alt-badge-engine-logo-white and color.svg ├── alt-badge-engine-logo-white.svg ├── badge-engine-icon.svg ├── by-dp-badge-engine-logo-black.svg ├── by-dp-badge-engine-logo-white and color copy.svg ├── by-dp-badge-engine-logo-white and color.svg ├── by-dp-badge-engine-logo-white.svg ├── digital-promise.svg ├── extensions │ ├── achievement_schema.json │ ├── assessmentExtension_context.json │ └── assessmentExtension_schema.json ├── primary-badge-engine-logo-black.svg ├── primary-badge-engine-logo-color.svg ├── primary-badge-engine-logo-white and color.svg └── primary-badge-engine-logo-white.svg ├── shared ├── examples │ └── openbadge-3-complete.json ├── interfaces │ ├── award.interface.ts │ ├── base-params.interface.ts │ ├── credential-form-object.interface.ts │ ├── functions.ts │ └── issuer-form-object-interface.ts ├── mongoose-schemas │ ├── auth-schemas │ │ ├── Account.ts │ │ ├── Session.ts │ │ ├── User.ts │ │ └── VerificationToken.ts │ └── obv3-schemas │ │ └── Profile.ts └── utils │ ├── config │ ├── mongodb.d.ts │ └── winston.d.ts │ ├── dataModFunctions.ts │ ├── dateFunctions.ts │ ├── logger │ ├── createLogger.ts │ └── winstonConfig.ts │ └── updateAction.ts ├── src ├── app │ ├── api │ │ ├── achievements │ │ │ └── route.ts │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── award │ │ │ └── [credentialId] │ │ │ │ └── route.ts │ │ ├── issuers │ │ │ └── route.ts │ │ ├── sign │ │ │ └── [achievementCredentialId] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── error.tsx │ ├── issuers │ │ ├── [id] │ │ │ ├── credentials │ │ │ │ ├── [credentialId] │ │ │ │ │ ├── CreatorLink.tsx │ │ │ │ │ ├── award │ │ │ │ │ │ ├── extras │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── new │ │ │ │ │ ├── criteria │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── extras │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── preview │ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── new │ │ │ └── page.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── not-found.tsx │ └── page.tsx ├── assets │ ├── fonts │ │ ├── Digital-Promise-Icons.svg │ │ ├── Digital-Promise-Icons.ttf │ │ ├── Digital-Promise-Icons.woff │ │ ├── index.ts │ │ ├── museo-300-italic.woff2 │ │ ├── museo-300-normal.woff2 │ │ ├── museo-500-italic.woff2 │ │ ├── museo-500-normal.woff2 │ │ ├── museo-700-italic.woff2 │ │ ├── museo-700-normal.woff2 │ │ ├── museo-900-italic.woff2 │ │ └── museo-900-normal.woff2 │ └── illustrations │ │ ├── badge-empty.svg │ │ ├── compass.svg │ │ ├── crow.svg │ │ ├── edtech.svg │ │ ├── institute-empty.svg │ │ └── lock.svg ├── components │ ├── Credential │ │ ├── AssessmentExtension.tsx │ │ ├── AwardHistory.tsx │ │ ├── ConfirmAndCreate.tsx │ │ ├── Credential.tsx │ │ ├── CredentialDetails.tsx │ │ ├── CredentialPreview.tsx │ │ ├── ResultDescription.tsx │ │ ├── Rubric.tsx │ │ └── TagList.tsx │ ├── CredentialCard.tsx │ ├── Tabs │ │ └── TabList.tsx │ ├── dialog.tsx │ ├── error.tsx │ ├── errors │ │ └── 403.tsx │ ├── forms │ │ ├── ForwardRefEditor.tsx │ │ ├── InitializedMDXEditor.tsx │ │ ├── achievement.tsx │ │ ├── award-form-nav.tsx │ │ ├── award.tsx │ │ ├── credential-form-nav.tsx │ │ ├── dropzone.tsx │ │ ├── errors.tsx │ │ ├── expiry.tsx │ │ ├── issuing-organization.tsx │ │ ├── list.tsx │ │ └── select-dropdown.tsx │ ├── global │ │ ├── auth-guard.tsx │ │ ├── breadcrumb.tsx │ │ ├── button-link.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── hr.tsx │ │ ├── menu-login.tsx │ │ ├── menu-primary.tsx │ │ └── notifications.tsx │ ├── icon.tsx │ ├── issuer.tsx │ ├── list-view.tsx │ ├── login.tsx │ ├── logout.tsx │ └── search.tsx ├── env.mjs ├── lib │ ├── api-middleware.ts │ ├── constants.ts │ ├── contexts.ts │ ├── create-signing-key.ts │ ├── document-loader.ts │ ├── error.ts │ ├── get-signing-key.ts │ ├── react-hook-form.ts │ └── submit.ts ├── modules.d.ts ├── providers │ ├── achievement-form-provider.tsx │ ├── award-form-provider.tsx │ ├── award-provider.tsx │ ├── issuer-provider.tsx │ ├── notification-provider.tsx │ └── notifications.tsx ├── server │ ├── actions │ │ ├── create-credential.ts │ │ ├── create-issuer.ts │ │ └── forms.ts │ ├── api │ │ ├── network-functions │ │ │ └── add-credential-status.ts │ │ ├── root.ts │ │ ├── routers │ │ │ ├── award.router.ts │ │ │ ├── credential.router.ts │ │ │ ├── image.router.ts │ │ │ ├── issuer.router.ts │ │ │ ├── signing.router.ts │ │ │ └── skills.ts │ │ ├── schemas │ │ │ ├── address.schema.ts │ │ │ ├── award.schema.ts │ │ │ ├── credential.schema.ts │ │ │ ├── geoCoordinates.schema.ts │ │ │ ├── identifierEntry.schema.ts │ │ │ ├── identifierTypeEnum.schema.ts │ │ │ ├── image.schema.ts │ │ │ ├── issuerProfile.schema.ts │ │ │ ├── open-badges │ │ │ │ ├── achievement.schema.ts │ │ │ │ ├── alignment.schema.ts │ │ │ │ ├── credential.schema.ts │ │ │ │ ├── identifier.schema.ts │ │ │ │ ├── image.schema.ts │ │ │ │ ├── profile.schema.ts │ │ │ │ └── proof.schema.ts │ │ │ ├── search-filter.schema.ts │ │ │ ├── status-schema.ts │ │ │ └── util.schema.ts │ │ └── trpc.ts │ ├── auth.ts │ └── db │ │ ├── mongoConnect.ts │ │ ├── mongooseConnect.ts │ │ ├── prismaConnect.ts │ │ └── queries.ts ├── stores │ ├── combinedAchievementStore.tsx │ └── slices │ │ ├── achievementSlice.tsx │ │ ├── basicsSlice.tsx │ │ ├── criteriaSlice.tsx │ │ ├── extrasSlice.tsx │ │ └── formStepSlice.tsx ├── styles │ ├── forms.css │ ├── globals.css │ ├── icons.css │ └── mdxeditor.css ├── trpc │ ├── react.tsx │ ├── server.ts │ └── shared.ts └── util.ts ├── style-dictionary ├── .gitignore ├── config.json └── properties │ ├── color │ ├── base.json │ └── theme.json │ └── size │ ├── font.json │ └── spacing.json ├── tailwind.config.ts ├── terraform ├── .gitignore ├── README.md ├── locals.tf ├── main.tf ├── modules │ ├── auth │ │ ├── main.tf │ │ └── variables.tf │ └── aws-secrets-and-iam-role │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf ├── outputs.tf ├── providers.tf ├── variables.tf └── version.tf └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this template to `.env` to begin configuring your local development environment. 2 | 3 | # Required 4 | 5 | # Prisma 6 | # @link https://www.prisma.io/docs/reference/database-reference/connection-urls#env 7 | 8 | DATABASE_URL="mongodb://mongodb:27017/badgingsoln?replicaSet=rs0&directConnection=true" 9 | 10 | # NextAuth 11 | 12 | # NEXTAUTH_SECRET="" # Required in production. 13 | NEXTAUTH_URL="https://badging.lndo.site" 14 | 15 | # Auth0 16 | # @link https://next-auth.js.org/providers/auth0#example 17 | AUTH0_CLIENT_ID="" 18 | AUTH0_CLIENT_SECRET="" 19 | AUTH0_ISSUER="" 20 | 21 | # Optional 22 | 23 | # DCC Status Service 24 | # STATUS_SERVICE_URL: "http://status:4008" 25 | # STATUS_LIST_URL: "" 26 | # STATUS_LIST_ID: "" 27 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: true, 6 | }, 7 | plugins: ["@typescript-eslint"], 8 | extends: [ 9 | "next/core-web-vitals", 10 | "plugin:@typescript-eslint/recommended-type-checked", 11 | "plugin:@typescript-eslint/stylistic-type-checked", 12 | ], 13 | rules: { 14 | // These opinionated rules are enabled in stylistic-type-checked above. 15 | // Feel free to reconfigure them to your own preference. 16 | "@typescript-eslint/array-type": "off", 17 | "@typescript-eslint/consistent-type-definitions": "off", 18 | 19 | "@typescript-eslint/consistent-type-imports": [ 20 | "warn", 21 | { 22 | prefer: "type-imports", 23 | fixStyle: "inline-type-imports", 24 | }, 25 | ], 26 | "@typescript-eslint/no-unused-vars": [ 27 | "warn", 28 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 29 | ], 30 | "@typescript-eslint/require-await": "off", 31 | "@typescript-eslint/no-misused-promises": [ 32 | "error", 33 | { 34 | checksVoidReturn: { attributes: false }, 35 | }, 36 | ], 37 | }, 38 | overrides: [ 39 | { 40 | files: ["tests/**/*"], 41 | plugins: ["jest"], 42 | env: { 43 | jest: true, 44 | }, 45 | }, 46 | ], 47 | }; 48 | 49 | module.exports = config; 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | /.pnp 6 | .pnp.js 7 | .pnpm-store 8 | 9 | # testing 10 | /coverage 11 | 12 | # database 13 | /prisma/db.sqlite 14 | /prisma/db.sqlite-journal 15 | 16 | # next.js 17 | .next/ 18 | /out/ 19 | next-env.d.ts 20 | .swc/ 21 | 22 | # production 23 | /build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | *:Zone.Identifier 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | .pnpm-debug.log* 35 | **/error.log* 36 | 37 | # local env files 38 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 39 | .env.* 40 | **/*.env 41 | .*.env 42 | !.env.example 43 | 44 | # lando 45 | .lando.local.yml 46 | 47 | # vercel 48 | .vercel 49 | 50 | # typescript 51 | *.tsbuildinfo 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # vscode 57 | .vscode 58 | -------------------------------------------------------------------------------- /.lando.local.example.yml: -------------------------------------------------------------------------------- 1 | # Create a .lando.local.yml file at the root of the project, copy the below information, and fill in with the appropriate values. 2 | status: 3 | overrides: 4 | environment: 5 | CRED_STATUS_SERVICE: github 6 | CRED_STATUS_REPO_OWNER: 7 | CRED_STATUS_REPO_NAME: 8 | CRED_STATUS_META_REPO_NAME: 9 | CRED_STATUS_ACCESS_TOKEN: 10 | CRED_STATUS_DID_SEED: # did:key seed 11 | -------------------------------------------------------------------------------- /.lando.yml: -------------------------------------------------------------------------------- 1 | name: badgeengine 2 | 3 | excludes: 4 | - .next 5 | - .pnpm-store 6 | 7 | services: 8 | appserver: 9 | api: 3 10 | type: node 11 | overrides: 12 | image: node:20 13 | environment: 14 | COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 15 | build_as_root: 16 | - corepack enable 17 | - corepack prepare pnpm --activate 18 | build: 19 | - pnpm install 20 | - pnpm style-dictionary 21 | port: 3000 22 | ssl: 4444 23 | command: pnpm dev 24 | 25 | mongodb: 26 | portforward: true 27 | type: mongo:7.0 28 | overrides: 29 | command: docker-entrypoint.sh --replSet rs0 30 | 31 | status: 32 | api: 3 33 | type: lando 34 | scanner: false 35 | app_mount: false 36 | services: 37 | image: digitalcredentials/status-service:0.1.0 38 | working_dir: /app 39 | command: node server.js 40 | ssl: false 41 | sslExpose: false 42 | port: 4008 43 | 44 | proxy: 45 | appserver: 46 | - badgeengine.lndo.site:3000 47 | - prisma.badgeengine.lndo.site:3100 48 | status: 49 | - status.badgeengine.lndo.site:4008 50 | 51 | tooling: 52 | pnpm: 53 | service: appserver 54 | events: 55 | post-start: 56 | - appserver: pm2 start "local-ssl-proxy --source 4444 --target 3000 --key /certs/cert.key --cert /certs/cert.crt" -s 57 | 58 | - mongodb: | 59 | mongosh --quiet --norc --eval "db.isMaster().primary || rs.initiate({_id: 'rs0', members: [{_id: 0, host: '127.0.0.1:27017'}]});" 60 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-import-method=clone-or-copy -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Badge Engine 2 | ## How to contribute 3 | * Submit an Issue for bug reporting 4 | * Submit a Discussion item for ideas or community engagement on a topic 5 | 6 | 7 | ### Developer's Certificate of Origin 1.1 8 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 9 | 10 | ```By making a contribution to this project, I certify that: 11 | 12 | (a) The contribution was created in whole or in part by me and I 13 | have the right to submit it under the open source license 14 | indicated in the file; or 15 | 16 | (b) The contribution is based upon previous work that, to the best 17 | of my knowledge, is covered under an appropriate open source 18 | license and I have the right under that license to submit that 19 | work with modifications, whether created in whole or in part 20 | by me, under the same open source license (unless I am 21 | permitted to submit under a different license), as indicated 22 | in the file; or 23 | 24 | (c) The contribution was provided directly to me by some other 25 | person who certified (a), (b) or (c) and I have not modified 26 | it. 27 | 28 | (d) I understand and agree that this project and the contribution 29 | are public and that a record of the contribution (including all 30 | personal information I submit with it, including my sign-off) is 31 | maintained indefinitely and may be redistributed consistent with 32 | this project or the open source license(s) involved.``` 33 | -------------------------------------------------------------------------------- /__mocks__/add-credential-status.test.ts: -------------------------------------------------------------------------------- 1 | import { addCredentialStatus } from "~/server/api/network-functions/add-credential-status"; 2 | import { 3 | achievementCredential, 4 | achievementCredentialProd, 5 | } from "shared/examples/sample-credentials/achievementCredential"; 6 | import { 7 | achievementCredentialWithStatus, 8 | achievementCredentialWithStatusProd, 9 | } from "shared/examples/sample-credentials/achievementCredentialWithStatus"; 10 | 11 | jest.mock("~/server/api/network-functions/add-credential-status"); 12 | 13 | describe("tests DCC status service to allocate a credential with a credentialStatus object", () => { 14 | const mockArgument = achievementCredential; 15 | const mockResponse = achievementCredentialWithStatus; 16 | 17 | afterEach(() => { 18 | jest.restoreAllMocks(); 19 | }); 20 | 21 | it("should return a mock credentialStatus object", async () => { 22 | const result = await addCredentialStatus(mockArgument); 23 | expect(result).toBe(mockResponse); 24 | }), 25 | it("should fail with an error", async () => { 26 | try { 27 | await addCredentialStatus(mockArgument); 28 | } catch (error) { 29 | expect(error).toMatch("error"); 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /__mocks__/empty.js: -------------------------------------------------------------------------------- 1 | // required for testing modules that use "server-only" and must be left blank 2 | -------------------------------------------------------------------------------- /__mocks__/nextFontMock.js: -------------------------------------------------------------------------------- 1 | module.exports = new Proxy( 2 | {}, 3 | { 4 | get: function getter() { 5 | return () => ({ 6 | className: "className", 7 | variable: "variable", 8 | style: { fontFamily: "fontFamily" }, 9 | }); 10 | }, 11 | }, 12 | ); 13 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | import nextJest from "next/jest.js"; 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: "./", 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | const config: Config = { 11 | collectCoverage: false, 12 | // on node 14.x coverage provider v8 offers good speed and more or less good report 13 | coverageProvider: "v8", 14 | // collectCoverageFrom: [ 15 | // "**/*.{js,jsx,ts,tsx}", 16 | // "!**/*.d.ts", 17 | // "!**/node_modules/**", 18 | // "!/out/**", 19 | // "!/.next/**", 20 | // "!/*.config.js", 21 | // "!/coverage/**", 22 | // ], 23 | moduleNameMapper: { 24 | // Handle CSS imports (with CSS modules) 25 | // https://jestjs.io/docs/webpack#mocking-css-modules 26 | "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy", 27 | 28 | // Handle CSS imports (without CSS modules) 29 | "^.+\\.(css|sass|scss)$": "/__mocks__/styleMock.js", 30 | 31 | // Handle image imports 32 | // https://jestjs.io/docs/webpack#handling-static-assets 33 | "^.+\\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$": `/__mocks__/fileMock.js`, 34 | 35 | // Handle module aliases 36 | "^@/components/(.*)$": "/components/$1", 37 | "^~/(.*)$": "/src/$1", 38 | 39 | // Handle @next/font 40 | "@next/font/(.*)": `/__mocks__/nextFontMock.js`, 41 | // Handle next/font 42 | "next/font/(.*)": `/__mocks__/nextFontMock.js`, 43 | // Disable server-only 44 | "server-only": `/__mocks__/empty.js`, 45 | }, 46 | // Add more setup options before each test is run 47 | // setupFilesAfterEnv: ['/jest.setup.js'], 48 | testPathIgnorePatterns: ["/node_modules/", "/.next/"], 49 | testEnvironment: "jsdom", 50 | transform: { 51 | // Use babel-jest to transpile tests with the next/babel preset 52 | // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object 53 | "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }], 54 | }, 55 | transformIgnorePatterns: [ 56 | "/node_modules/", 57 | "^.+\\.module\\.(css|sass|scss)$", 58 | ], 59 | }; 60 | 61 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 62 | export default createJestConfig(config); 63 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.mjs"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | transpilePackages: ['@mdxeditor/editor'], 10 | }; 11 | 12 | if (process.env.NODE_ENV === 'development') { 13 | // Don't break the local front-end creating builds during development. 14 | config.output = 'standalone' 15 | } 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ 2 | const config = { 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /prisma/schema/_base.prisma: -------------------------------------------------------------------------------- 1 | // This is our base Prisma schema file, 2 | // learn more about them in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | binaryTargets = ["native", "rhel-openssl-1.0.x", "darwin-arm64"] 7 | previewFeatures = ["prismaSchemaFolder"] 8 | } 9 | 10 | datasource db { 11 | provider = "mongodb" 12 | // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below 13 | // Further reading: 14 | // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema 15 | // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string 16 | url = env("DATABASE_URL") 17 | relationMode = "prisma" 18 | } 19 | -------------------------------------------------------------------------------- /prisma/schema/alignment.prisma: -------------------------------------------------------------------------------- 1 | // https://www.imsglobal.org/spec/ob/v3p0#alignment 2 | // Describes an alignment between an achievement and a node in an educational framework. 3 | // This class can be extended with additional properties. 4 | model Alignment { 5 | id String @id @default(auto()) @map("_id") @db.ObjectId 6 | // The value of the 'type' property MUST be an unordered set. One of the items MUST be the IRI 'Alignment'. [1..*] 7 | type String[] 8 | // 'targetCode' = locally unique string identifier that identifies the alignment target within its framework and/or targetUrl. [0..1] 9 | targetCode String? 10 | // Short description of the alignment target.[0..1] 11 | targetDescription String? 12 | // Name of the alignment. [1] 13 | targetName String 14 | // Name of the framework the alignment target. [0..1] 15 | targetFramework String? 16 | // The type of the alignment target node. [0..1] 17 | targetType AlignmentTargetType? // optional 18 | // URL linking to the official description of the alignment target, for example an individual standard within an educational framework. [1] 19 | targetUrl String 20 | 21 | // *NOTE: This class can be extended with additional properties. 22 | 23 | // Model relationships 24 | achievements Achievement[] @relation(fields: [achievementIds], references: [docId]) 25 | achievementIds String[] @db.ObjectId 26 | Result Result? @relation(fields: [resultId], references: [id], name: "result", onDelete: NoAction, onUpdate: NoAction) 27 | resultId String? @db.ObjectId 28 | ResultDescription ResultDescription? @relation(fields: [resultDescriptionId], references: [docId], name: "resultDescription", onDelete: NoAction, onUpdate: NoAction) 29 | resultDescriptionId String? @db.ObjectId 30 | RubricCriterionLevel RubricCriterionLevel? @relation(fields: [rubricCriterionlevelId], references: [docId], name: "rubricCriterionLevel", onDelete: NoAction, onUpdate: NoAction) 31 | rubricCriterionlevelId String? @db.ObjectId 32 | } 33 | 34 | // https://www.imsglobal.org/spec/ob/v3p0#org.1edtech.ob.v3p0.alignmenttargettype.class 35 | // The type of the alignment target node in the target framework. 36 | // This enumeration can be extended with new, proprietary terms. The new terms must start with the substring 'ext:'. 37 | enum AlignmentTargetType { 38 | // 'ceasn:Competency' = An alignment to a CTDL-ASN/CTDL competency published by Credential Engine. 39 | ceasnCompentency @map("ceasn:Competency") 40 | // 'ceterms:Credential' = An alignment to a CTDL Credential published by Credential Engine. 41 | cetermsCredential @map("ceterms:Credential") 42 | // 'CFItem' = An alignment to a CASE Framework Item. 43 | CFItem 44 | // 'CFRubric' = An alignment to a CASE Framework Rubric. 45 | CFRubric 46 | // 'CFRubricCriterion' = An alignment to a CASE Framework Rubric Criterion. 47 | CFRubricCriterion 48 | // 'CFRubricCriterionLevel' = An alignment to a CASE Framework Rubric Criterion Level. 49 | CFRubricCriterionLevel 50 | // 'CTDL' = An alignment to a Credential Engine Item. 51 | CTDL 52 | 53 | // *NOTE: This enumeration can be extended with new, proprietary terms. The new terms must start with the substring 'ext:'. 54 | } 55 | -------------------------------------------------------------------------------- /prisma/schema/auth.prisma: -------------------------------------------------------------------------------- 1 | // Necessary for Next auth 2 | model Account { 3 | id String @id @default(auto()) @map("_id") @db.ObjectId 4 | userId String @db.ObjectId 5 | type String 6 | provider String 7 | providerAccountId String 8 | refresh_token String? @db.String 9 | access_token String? @db.String 10 | id_token String? @db.String 11 | expires_at Int? 12 | token_type String? 13 | scope String? 14 | session_state String? 15 | 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | 19 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 20 | 21 | @@unique([provider, providerAccountId]) 22 | } 23 | 24 | model Session { 25 | id String @id @default(auto()) @map("_id") @db.ObjectId 26 | sessionToken String @unique 27 | userId String @db.ObjectId 28 | expires DateTime 29 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 30 | 31 | createdAt DateTime @default(now()) 32 | updatedAt DateTime @updatedAt 33 | } 34 | 35 | model User { 36 | id String @id @default(auto()) @map("_id") @db.ObjectId 37 | createdAt DateTime @default(now()) 38 | updatedAt DateTime @updatedAt 39 | lastLogin DateTime @default(now()) 40 | isActive Boolean @default(true) 41 | isSuperUser Boolean @default(false) 42 | // `role` = Defines role-based access. Allows for issuer access and levelled admin access with specific permissions. At that time this will convert to `String[]` and enum values will be defined, as well as possibly removing `isSuperUser` field. 43 | role String? 44 | name String? 45 | email String @unique 46 | emailVerified Boolean? 47 | image String? 48 | accounts Account[] 49 | sessions Session[] 50 | } 51 | 52 | model VerificationToken { 53 | id String @id @default(auto()) @map("_id") @db.ObjectId 54 | identifier String 55 | token String @unique 56 | expires DateTime 57 | 58 | @@unique([identifier, token]) 59 | } 60 | 61 | // Internal use for role-based access permissions 62 | enum RolePermissions { 63 | ASSUMEROLESECRETMANAGERREAD 64 | } 65 | -------------------------------------------------------------------------------- /prisma/schema/context.prisma: -------------------------------------------------------------------------------- 1 | // https://www.imsglobal.org/spec/ob/v3p0#org.1edtech.ob.v3p0.credentialschema.class 2 | // Identify the type and location of a data schema. 3 | model CredentialSchema { 4 | docId String @id @default(auto()) @map("_id") @db.ObjectId 5 | // The value MUST be a URI identifying the schema file. One instance of CredentialSchema MUST have an id that is the URL of the JSON Schema for this credential defined by this specification. [1] 6 | // Add the URI for the achievement_schema.json when available 7 | id String 8 | // 'type' = The value MUST identify the type of data schema validation. One instance of CredentialSchema MUST have a type of 'JsonSchemaValidator2019'. [1] 9 | type String 10 | 11 | //{ 12 | //... 13 | //"credentialSchema": [{ 14 | //"id": "https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json", 15 | //"type": "1EdTechJsonSchemaValidator2019" 16 | //}, { 17 | //"id": "https://your_url/your_schema.json", 18 | //"type": "1EdTechJsonSchemaValidator2019" 19 | //}] 20 | //... 21 | //} 22 | 23 | // Model relationships 24 | EndorsementCredential EndorsementCredential? @relation(fields: [endorsementCredentialId], references: [docId], onDelete: Cascade) 25 | endorsementCredentialId String? @db.ObjectId 26 | AchievementCredential AchievementCredential? @relation(fields: [achievementCredentialId], references: [docId], name: "achievementCredential", onDelete: Cascade) 27 | achievementCredentialId String? @db.ObjectId 28 | VerifiableCredential VerifiableCredential? @relation(fields: [verifiableCredentialId], references: [docId], name: "verifiableCredential", onDelete: Cascade) 29 | verifiableCredentialId String? @db.ObjectId 30 | AssessmentExtension AssessmentExtension? @relation(fields: [assessmentExtensionId], references: [id], onDelete: Cascade) 31 | assessmentExtensionId String? @db.ObjectId 32 | // *NOTE: This class can be extended with additional properties. 33 | } 34 | -------------------------------------------------------------------------------- /prisma/schema/extensions.prisma: -------------------------------------------------------------------------------- 1 | // TODO: host JSON-LD and context on server 2 | // https://www.imsglobal.org/spec/ob/v3p0#extending 3 | 4 | model Extensions { 5 | id String @id @default(auto()) @map("_id") @db.ObjectId 6 | achievementId String @unique @db.ObjectId 7 | achievement Achievement @relation("AchievementToExtensions", fields: [achievementId], references: [docId], onDelete: Cascade) 8 | 9 | // Relation to AssessmentExtension; multiple extensions per achievement 10 | assessmentExtensions AssessmentExtension[] @relation(name: "ExtensionsToAssessmentExtensions") 11 | } 12 | 13 | model AssessmentExtension { 14 | id String @id @default(auto()) @map("_id") @db.ObjectId 15 | // '@context' = The value of the @context property MUST be an ordered set where the first item is a URI with the value 'https://www.w3.org/ns/credentials/v2', and the second item is a URI with the value 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json'. [1..*] 16 | context String[] 17 | type String[] // Extension, AssessmentExtension 18 | supportingResearchAndRationale String? 19 | resources String? 20 | credentialSchema CredentialSchema[] 21 | 22 | // Model Relationships 23 | achievementId String @db.ObjectId 24 | achievement Achievement @relation("AchievementToAssessmentExtensions", fields: [achievementId], references: [docId], onDelete: Cascade) 25 | extensionsId String @db.ObjectId 26 | extensions Extensions @relation("ExtensionsToAssessmentExtensions", fields: [extensionsId], references: [id], onDelete: Cascade) 27 | } 28 | -------------------------------------------------------------------------------- /prisma/schema/identifier.prisma: -------------------------------------------------------------------------------- 1 | // https://www.imsglobal.org/spec/ob/v3p0#org.1edtech.ob.v3p0.identifierentry.class 2 | model IdentifierEntry { 3 | id String @id @default(auto()) @map("_id") @db.ObjectId 4 | // 'type' = The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'IdentifierEntry'. [1] 5 | type String @default("IdentifierEntry") 6 | // 'identifier' = A NormalizedString that functions as an identifier. [1] 7 | identifier String 8 | // 'identifierType' = The identifier type (enum). [1] 9 | identifierType IdentifierType 10 | 11 | // Model relationships 12 | Profile Profile @relation(fields: [profileId], references: [docId], onDelete: Cascade) 13 | profileId String @db.ObjectId 14 | Achievement Achievement? @relation(fields: [achievementId], references: [docId], name: "achievement", onDelete: Cascade) 15 | achievementId String? @db.ObjectId 16 | } 17 | 18 | // https://www.imsglobal.org/spec/ob/v3p0#org.1edtech.ob.v3p0.identifiertypeenum.class 19 | // *NOTE: This enumeration can be extended with new, proprietary terms. The new terms must start with the substring 'ext:'. 20 | enum IdentifierType { 21 | name 22 | sourcedId 23 | systemId 24 | productId 25 | userName 26 | accountId 27 | emailAddress 28 | nationalIdentityNumber 29 | isbn 30 | issn 31 | lisSourcedId 32 | oneRosterSourcedId 33 | sisSourcedId 34 | ltiContextId 35 | ltiDeploymentId 36 | ltiToolId 37 | ltiPlatformId 38 | ltiUserId 39 | identifier 40 | 41 | // *NOTE: This enumeration can be extended with new, proprietary terms. The new terms must start with the substring 'ext:'. 42 | } 43 | -------------------------------------------------------------------------------- /prisma/schema/multikey.prisma: -------------------------------------------------------------------------------- 1 | model Multikey { 2 | docId String @id @default(auto()) @map("_id") @db.ObjectId 3 | id String @unique 4 | publicKeyMultibase String 5 | seed String? // This is the encoded seed and needs to be decoded for use in env vars 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema/types.prisma: -------------------------------------------------------------------------------- 1 | type CriteriaType { 2 | // The URI of a webpage that describes in a human-readable format the criteria for the achievement. 3 | id String? 4 | // A narrative of what is needed to earn the achievement. Markdown is allowed. 5 | narrative String? 6 | } -------------------------------------------------------------------------------- /public/CC-BY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/public/CC-BY.png -------------------------------------------------------------------------------- /public/badge-engine-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/extensions/achievement_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2019-09/schema#", 3 | "$id": "tbd", 4 | "type": "object", 5 | "properties": { 6 | "achievement": { 7 | "type": "object", 8 | "properties": { 9 | "AssessmentExtension": { 10 | "type": "object" 11 | } 12 | }, 13 | "additionalProperties": true 14 | } 15 | }, 16 | "additionalProperties": true 17 | } 18 | -------------------------------------------------------------------------------- /public/extensions/assessmentExtension_context.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": { 3 | "obi": "https://w3id.org/openbadges#", 4 | "extensions": "https://w3id.org/openbadges/extensions#", 5 | "schema": "http://schema.org/", 6 | "case": "https://purl.imsglobal.org/spec/case/vocab#", 7 | 8 | "supportingResearchAndRationale": "extensions:supportingResearchAndRationale", 9 | "resources": "extensions:resources" 10 | }, 11 | "obi:validation": [ 12 | { 13 | "obi:validatesType": "extensions:AssessmentExtension", 14 | "obi:validationSchema": "" 15 | } 16 | ] 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/extensions/assessmentExtension_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "Assessment", 4 | "description": "This extension provides information about single or multiple assessments that would be completed by the recipient as part of the requirements for earning an OpenBadge. There could be multiple assessments of different types for each badge earned. Separate, independent evaluations of a single assessment could result in multiple assessment/evaluation records, all included in a single instance of the extension. ", 5 | "AssessmentObject": { 6 | "type": "object", 7 | "properties": { 8 | "supportingResearchAndRational": { 9 | "description": "This field provides peer reviewed and published citations for the achievement. Because our achievements are research-based, we put the research we used to write them in this section.", 10 | "type": "string" 11 | }, 12 | "resources": { 13 | "description": "This field provides practice-based items (blog posts, videos) that can be used support users as they demonstrate their work.", 14 | "type": "string" 15 | } 16 | }, 17 | "required": ["type"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/interfaces/award.interface.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { AchievementType, AssessmentPlacement } from "@prisma/client"; 3 | import { nonEmptyString } from "./functions"; 4 | 5 | export const AwardTargetSchema = z.object({ 6 | name: nonEmptyString(), 7 | }) 8 | 9 | -------------------------------------------------------------------------------- /shared/interfaces/base-params.interface.ts: -------------------------------------------------------------------------------- 1 | export default interface BaseInputParams { 2 | params: { 3 | id: string 4 | } 5 | } -------------------------------------------------------------------------------- /shared/interfaces/functions.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const nonEmptyString = ( 4 | min = 1, 5 | message = "This is a required field.", 6 | ) => z.string().trim().min(min, { message }); 7 | -------------------------------------------------------------------------------- /shared/interfaces/issuer-form-object-interface.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const ProfileSchema = z.object({ 4 | name: z.string(), 5 | email: z.string(), 6 | url: z.string(), 7 | description: z.string().optional(), 8 | image: z.instanceof(Blob).optional(), 9 | isPublic: z.preprocess((v) => Boolean(v), z.boolean()), 10 | }); 11 | 12 | export type IssuerForm = z.infer; 13 | -------------------------------------------------------------------------------- /shared/mongoose-schemas/auth-schemas/Account.ts: -------------------------------------------------------------------------------- 1 | import { type Model, Schema, model } from "mongoose"; 2 | 3 | /** 4 | * Auth.js uses camelCase for its database rows while respecting the conventional snake_case formatting for OAuth-related values. 5 | * @see https://authjs.dev/concepts/database-models 6 | */ 7 | 8 | export interface IAccount { 9 | userId: Schema.Types.ObjectId; 10 | type: string; 11 | provider: string; 12 | providerAccountId: string; 13 | refresh_token?: string | null; 14 | access_token: string; 15 | expires_at: number; 16 | token_type: string; 17 | scope: string; 18 | id_token: string; 19 | session_state: string; 20 | createdAt: Date; 21 | updatedAt: Date; 22 | } 23 | 24 | /** 25 | * Put all user instance methods in this interface: 26 | * @see https://mongoosejs.com/docs/typescript/statics-and-methods.html 27 | */ 28 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 29 | interface IAccountMethods {} 30 | 31 | // Create a new Model type that knows about IAccountMethods 32 | type AccountModel = Model; 33 | 34 | const accountSchema = new Schema( 35 | { 36 | userId: { 37 | type: Schema.Types.ObjectId, 38 | required: true, 39 | ref: "User", 40 | }, 41 | type: { 42 | type: String, 43 | required: true, 44 | }, 45 | provider: { 46 | type: String, 47 | required: true, 48 | }, 49 | providerAccountId: { 50 | type: String, 51 | required: true, 52 | unique: true, 53 | }, 54 | refresh_token: { 55 | type: String, 56 | default: null, 57 | }, 58 | access_token: { 59 | type: String, 60 | required: true, 61 | }, 62 | expires_at: { 63 | type: Number, 64 | required: true, 65 | }, 66 | token_type: { 67 | type: String, 68 | required: true, 69 | }, 70 | scope: { 71 | type: String, 72 | required: true, 73 | }, 74 | id_token: { 75 | type: String, 76 | required: true, 77 | }, 78 | session_state: { 79 | type: String, 80 | }, 81 | }, 82 | /** 83 | * Adding { timestamps: true } will add createdAt and updatedAt automatically. 84 | * @see https://mongoosejs.com/docs/timestamps.html 85 | * */ 86 | { 87 | timestamps: true, 88 | }, 89 | ); 90 | 91 | // Add schema methods here that were defined above 92 | 93 | export default model("Account", accountSchema); 94 | -------------------------------------------------------------------------------- /shared/mongoose-schemas/auth-schemas/Session.ts: -------------------------------------------------------------------------------- 1 | import { type Model, Schema, model } from "mongoose"; 2 | 3 | export interface ISession { 4 | sessionToken: string; 5 | userId: Schema.Types.ObjectId; 6 | expires: Date; 7 | createdAt: Date; 8 | updatedAt: Date; 9 | } 10 | 11 | /** 12 | * Put all user instance methods in this interface: 13 | * @see https://mongoosejs.com/docs/typescript/statics-and-methods.html 14 | */ 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 17 | interface ISessionMethods {} 18 | 19 | // Create a new Model type that knows about ISessionMethods 20 | type SessionModel = Model; 21 | 22 | const sessionSchema = new Schema( 23 | { 24 | sessionToken: { 25 | type: String, 26 | required: true, 27 | unique: true, 28 | }, 29 | userId: { 30 | type: Schema.Types.ObjectId, 31 | required: true, 32 | ref: "User", 33 | }, 34 | expires: { 35 | type: Date, 36 | required: true, 37 | }, 38 | }, 39 | /** 40 | * Adding { timestamps: true } will add createdAt and updatedAt automatically. 41 | * @see https://mongoosejs.com/docs/timestamps.html 42 | * */ 43 | { 44 | timestamps: true, 45 | }, 46 | ); 47 | 48 | // Add schema methods here that were defined above 49 | 50 | export default model("Session", sessionSchema); 51 | -------------------------------------------------------------------------------- /shared/mongoose-schemas/auth-schemas/User.ts: -------------------------------------------------------------------------------- 1 | import { type Model, Schema, model } from "mongoose"; 2 | 3 | export interface IUser { 4 | name?: string | null; 5 | email: string; 6 | emailVerifiedDate?: Date | null; 7 | image?: string | null; 8 | role?: string[] | null; 9 | isSuperUser: boolean; 10 | isActive: boolean; 11 | createdAt: Date; 12 | updatedAt: Date; 13 | lastLogin: Date; 14 | } 15 | 16 | /** 17 | * Put all user instance methods in this interface: 18 | * @see https://mongoosejs.com/docs/typescript/statics-and-methods.html 19 | */ 20 | 21 | interface IUserMethods { 22 | fullName(): string; 23 | } 24 | 25 | // Create a new Model type that knows about IUserMethods 26 | type UserModel = Model; 27 | 28 | const userSchema = new Schema( 29 | { 30 | name: { 31 | type: String, 32 | }, 33 | email: { 34 | type: String, 35 | required: true, 36 | /** Will need to be careful about duplicate documents. No support for `dropDups` so they will need to be manually removed if the unique constraint fails. */ 37 | unique: true, 38 | }, 39 | emailVerifiedDate: { 40 | type: Date, 41 | default: null, 42 | }, 43 | image: { 44 | type: String, 45 | }, 46 | role: { 47 | type: [String], 48 | }, 49 | isSuperUser: { 50 | type: Boolean, 51 | required: true, 52 | default: false, 53 | }, 54 | isActive: { 55 | type: Boolean, 56 | required: true, 57 | default: true, 58 | }, 59 | createdAt: { 60 | type: Date, 61 | default: () => Date.now(), 62 | immutable: true, 63 | }, 64 | updatedAt: Date, 65 | lastLogin: { 66 | type: Date, 67 | required: true, 68 | // TODO: validate that this is entered in ISO format 69 | // default: new Date(Date.now()), 70 | default: () => Date.now(), 71 | }, 72 | }, 73 | /** 74 | * Adding { timestamps: true } will add createdAt and updatedAt automatically. Doesn't work with virtuals. 75 | * @see https://mongoosejs.com/docs/timestamps.html 76 | * */ 77 | { 78 | timestamps: true, 79 | }, 80 | ); 81 | 82 | // Add schema methods here that were defined above 83 | 84 | const User = model("User", userSchema); 85 | 86 | export default User; 87 | -------------------------------------------------------------------------------- /shared/mongoose-schemas/auth-schemas/VerificationToken.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | /* 4 | @see https://authjs.dev/concepts/database-models#verificationtoken 5 | */ 6 | export interface IVerificationToken { 7 | identifier: string; 8 | token: string; 9 | expires: Date; 10 | } 11 | 12 | const verificationTokenSchema = new Schema({ 13 | identifier: { 14 | type: String, 15 | required: true, 16 | unique: true, 17 | }, 18 | token: { 19 | type: String, 20 | required: true, 21 | unique: true, 22 | }, 23 | expires: { 24 | type: Date, 25 | required: true, 26 | }, 27 | }); 28 | 29 | export default model( 30 | "VerificationToken", 31 | verificationTokenSchema, 32 | ); 33 | -------------------------------------------------------------------------------- /shared/utils/config/mongodb.d.ts: -------------------------------------------------------------------------------- 1 | import type { MongoClient } from "mongodb"; 2 | 3 | declare global { 4 | const _mongoClientPromise: Promise; 5 | } 6 | -------------------------------------------------------------------------------- /shared/utils/config/winston.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for winston 3.0 2 | // Project: https://github.com/winstonjs/winston 3 | 4 | /// 5 | 6 | declare namespace winston { 7 | type AbstractConfigSetLevels = Record; 8 | 9 | type AbstractConfigSetColors = Record; 10 | 11 | interface AbstractConfigSet { 12 | levels: AbstractConfigSetLevels; 13 | colors: AbstractConfigSetColors; 14 | } 15 | 16 | interface CliConfigSetLevels extends AbstractConfigSetLevels { 17 | error: number; 18 | warn: number; 19 | help: number; 20 | data: number; 21 | info: number; 22 | debug: number; 23 | prompt: number; 24 | verbose: number; 25 | input: number; 26 | silly: number; 27 | } 28 | 29 | interface CliConfigSetColors extends AbstractConfigSetColors { 30 | error: string | string[]; 31 | warn: string | string[]; 32 | help: string | string[]; 33 | data: string | string[]; 34 | info: string | string[]; 35 | debug: string | string[]; 36 | prompt: string | string[]; 37 | verbose: string | string[]; 38 | input: string | string[]; 39 | silly: string | string[]; 40 | } 41 | 42 | interface NpmConfigSetLevels extends AbstractConfigSetLevels { 43 | error: number; 44 | warn: number; 45 | info: number; 46 | http: number; 47 | verbose: number; 48 | debug: number; 49 | silly: number; 50 | } 51 | 52 | interface NpmConfigSetColors extends AbstractConfigSetColors { 53 | error: string | string[]; 54 | warn: string | string[]; 55 | info: string | string[]; 56 | http: string | string[]; 57 | verbose: string | string[]; 58 | debug: string | string[]; 59 | silly: string | string[]; 60 | } 61 | 62 | interface SyslogConfigSetLevels extends AbstractConfigSetLevels { 63 | emerg: number; 64 | alert: number; 65 | crit: number; 66 | error: number; 67 | warning: number; 68 | notice: number; 69 | info: number; 70 | debug: number; 71 | } 72 | 73 | interface SyslogConfigSetColors extends AbstractConfigSetColors { 74 | emerg: string | string[]; 75 | alert: string | string[]; 76 | crit: string | string[]; 77 | error: string | string[]; 78 | warning: string | string[]; 79 | notice: string | string[]; 80 | info: string | string[]; 81 | debug: string | string[]; 82 | } 83 | 84 | interface Config { 85 | allColors: AbstractConfigSetColors; 86 | cli: { levels: CliConfigSetLevels; colors: CliConfigSetColors }; 87 | npm: { levels: NpmConfigSetLevels; colors: NpmConfigSetColors }; 88 | syslog: { levels: SyslogConfigSetLevels; colors: SyslogConfigSetColors }; 89 | 90 | addColors(colors: AbstractConfigSetColors): void; 91 | } 92 | } 93 | 94 | declare const winston: winston.Config; 95 | export = winston; 96 | -------------------------------------------------------------------------------- /shared/utils/dataModFunctions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-nocheck 6 | 7 | // convert string to an array 8 | export function stringToArray(value: string) { 9 | const array = []; 10 | array.push(value); 11 | return array; 12 | } 13 | 14 | // rename json keys 15 | export function renameKey(obj, oldKey, newKey) { 16 | console.log("oldKey: ", oldKey); 17 | console.log("newKey: ", newKey); 18 | 19 | if (oldKey !== newKey) { 20 | // Modify old key 21 | Object.defineProperty( 22 | obj, 23 | newKey, 24 | // Fetch description from object 25 | Object.getOwnPropertyDescriptor(obj, oldKey), 26 | ); 27 | // Delete old key 28 | delete obj[oldKey]; 29 | console.log("new obj ", obj); 30 | 31 | return obj; 32 | } 33 | return obj; 34 | } 35 | -------------------------------------------------------------------------------- /shared/utils/dateFunctions.ts: -------------------------------------------------------------------------------- 1 | export const startDate = new Date(Date.now()); 2 | -------------------------------------------------------------------------------- /shared/utils/logger/createLogger.ts: -------------------------------------------------------------------------------- 1 | import winston, { format } from "winston"; 2 | import winstonConfig from "./winstonConfig"; 3 | 4 | const { 5 | combine, 6 | errors, 7 | prettyPrint, 8 | json, 9 | timestamp, 10 | colorize, 11 | simple, 12 | printf, 13 | } = format; 14 | 15 | winston.addColors(winstonConfig.colors); 16 | 17 | const logFormat = printf(({ level, message, timestamp }) => { 18 | return `${level}: ${message} - ${timestamp}\x1b[0m `; 19 | }); 20 | 21 | const logger = winston.createLogger({ 22 | levels: winstonConfig.levels, 23 | format: combine( 24 | prettyPrint(), 25 | json(), 26 | timestamp(), 27 | colorize(), 28 | simple(), 29 | errors({ stack: true }), 30 | logFormat, 31 | ), 32 | transports: [ 33 | // - Write all logs with importance level of `error` or less to `error.log` 34 | new winston.transports.Console({ 35 | level: "error", 36 | format: printf((info) => { 37 | return `${info.timestamp} : ${info.message}\n 38 | ${JSON.stringify(info.meta, null, 4)}`; 39 | // Meta as additional data you provide while logging 40 | }), 41 | }), 42 | ], 43 | level: "custom", 44 | }); 45 | 46 | // If we're not in production then log to the console 47 | if (process.env.NODE_ENV !== "production") { 48 | logger.add( 49 | new winston.transports.Console({ 50 | format: combine(colorize({ all: true }), simple(), logFormat), 51 | }), 52 | ); 53 | } 54 | 55 | export default logger; 56 | -------------------------------------------------------------------------------- /shared/utils/logger/winstonConfig.ts: -------------------------------------------------------------------------------- 1 | const winstonConfig = { 2 | levels: { 3 | error: 0, 4 | debug: 1, 5 | warn: 2, 6 | data: 3, 7 | info: 4, 8 | verbose: 5, 9 | silly: 6, 10 | custom: 7, 11 | }, 12 | colors: { 13 | error: "red", 14 | debug: "blue", 15 | warn: "yellow", 16 | data: "grey", 17 | info: "green", 18 | verbose: "cyan", 19 | silly: "magenta", 20 | custom: "yellow", 21 | }, 22 | }; 23 | 24 | export default winstonConfig; 25 | -------------------------------------------------------------------------------- /shared/utils/updateAction.ts: -------------------------------------------------------------------------------- 1 | export default function updateAction(state: object, payload: object) { 2 | console.log("state: ", state); 3 | console.log("payload: ", payload); 4 | return { 5 | ...state, 6 | ...payload, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/api/achievements/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "~/trpc/server"; 2 | import { type NextRequest, NextResponse } from "next/server"; 3 | import { isAuthorized } from "~/lib/api-middleware"; 4 | import { env } from "~/env.mjs"; 5 | 6 | const maybeAddHttps = (url: string) => { 7 | if (!/^(?:f|ht)tps?\:\/\//.test(url)) { 8 | url = "https://" + url; 9 | } 10 | return url; 11 | }; 12 | 13 | function paginationLink(page: number, rel: string) { 14 | const baseUrl = maybeAddHttps(env.NEXTAUTH_URL) 15 | const url = new URL(`/api/achievements?page=${page}`, baseUrl) 16 | 17 | return `<${url.toString()}>; rel="${rel}"`; 18 | } 19 | 20 | export const GET = async function GET( 21 | req: NextRequest, 22 | ) { 23 | const authorization = await isAuthorized(req); 24 | 25 | if (authorization) { 26 | const limit = 20; 27 | const { searchParams } = new URL(req.url); 28 | const pageParam = searchParams.get("page") 29 | const currentPage = pageParam ? parseInt(pageParam) : 1 30 | 31 | const [total, _searchTotal, achievements] = 32 | await api.credential.index.query({ 33 | includeAll: true, 34 | page: currentPage, 35 | limit, 36 | }); 37 | 38 | const response = NextResponse.json(achievements); 39 | 40 | if (total > limit) { 41 | const lastPage = Math.ceil(total / limit); 42 | 43 | const links = [ 44 | paginationLink(1, "first"), 45 | paginationLink(lastPage, "last"), 46 | ]; 47 | 48 | if (currentPage < lastPage) { 49 | links.push(paginationLink(currentPage + 1, "next")); 50 | } 51 | 52 | if (currentPage > 1) { 53 | links.push(paginationLink(currentPage - 1, "prev")); 54 | } 55 | 56 | response.headers.set("X-Total-Count", total.toString()); 57 | response.headers.set("Link", links.join(",")); 58 | } 59 | 60 | return response; 61 | } 62 | 63 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 64 | }; 65 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import NextAuth from "next-auth"; 3 | import { authOptions } from "~/server/auth"; 4 | 5 | const handler = NextAuth(authOptions); 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/app/api/award/[credentialId]/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "~/trpc/server"; 2 | import { type NextRequest, NextResponse } from "next/server"; 3 | import { isAuthorized } from "~/lib/api-middleware"; 4 | import { CreateAwardSchema } from "~/server/api/schemas/award.schema"; 5 | 6 | const POST = async function POST( 7 | req: NextRequest, 8 | { params: { credentialId } }: { params: { credentialId: string } }, 9 | ) { 10 | const authorization = await isAuthorized(req); 11 | 12 | if (authorization) { 13 | try { 14 | const awardInput = CreateAwardSchema.omit({ 15 | credentialId: true, 16 | }).parse(await req.json()); 17 | 18 | const newAward = await api.award.create.mutate({ 19 | ...awardInput, 20 | credentialId, 21 | }); 22 | return NextResponse.json(newAward); 23 | } catch (e) { 24 | return NextResponse.json( 25 | { error: (e as Error).message }, 26 | { status: 500 }, 27 | ); 28 | } 29 | } 30 | 31 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 32 | }; 33 | 34 | export { POST }; 35 | -------------------------------------------------------------------------------- /src/app/api/issuers/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "~/trpc/server"; 2 | import { type NextRequest, NextResponse } from "next/server"; 3 | import { isAuthorized } from "~/lib/api-middleware"; 4 | 5 | export const GET = async function GET(req: NextRequest) { 6 | const authorization = await isAuthorized(req); 7 | 8 | if (authorization) { 9 | const issuers = await api.issuer.index.query({}); 10 | 11 | return NextResponse.json(issuers); 12 | } 13 | 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/api/sign/[achievementCredentialId]/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "~/trpc/server"; 2 | import { type NextRequest, NextResponse } from "next/server"; 3 | import { isAuthorized } from "~/lib/api-middleware"; 4 | 5 | const POST = async function POST( 6 | req: NextRequest, 7 | { 8 | params: { achievementCredentialId }, 9 | }: { params: { achievementCredentialId: string } }, 10 | ) { 11 | const authorization = await isAuthorized(req); 12 | 13 | if (authorization) { 14 | try { 15 | const { context, ...rest } = await api.award.find.query( 16 | achievementCredentialId, 17 | ); 18 | 19 | const signedCredential = await api.signing.createProof.mutate({ 20 | context: context.length >= 2 ? context : undefined, 21 | ...rest, 22 | type: ["VerifiableCredential", ...rest.type], 23 | }); 24 | 25 | return NextResponse.json(signedCredential); 26 | } catch (e) { 27 | return NextResponse.json(JSON.parse((e as Error).message), { 28 | status: 500, 29 | }); 30 | } 31 | } 32 | 33 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 34 | }; 35 | 36 | export { POST }; 37 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { type NextRequest } from "next/server"; 3 | import { env } from "~/env.mjs"; 4 | import { appRouter } from "~/server/api/root"; 5 | import { createTRPCContext } from "~/server/api/trpc"; 6 | 7 | /** 8 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 9 | * handling a HTTP request (e.g. when you make requests from Client Components). 10 | */ 11 | 12 | const createContext = async (req: NextRequest) => { 13 | return createTRPCContext({ 14 | headers: req.headers, 15 | }); 16 | }; 17 | 18 | const handler = (req: NextRequest) => 19 | fetchRequestHandler({ 20 | endpoint: "/api/trpc", 21 | req, 22 | router: appRouter, 23 | createContext: () => createContext(req), 24 | onError: 25 | env.NODE_ENV === "development" 26 | ? ({ path, error }) => { 27 | console.error( 28 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 29 | ); 30 | } 31 | : undefined, 32 | }); 33 | 34 | export { handler as GET, handler as POST }; 35 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error boundaries must be Client Components 2 | 3 | import { useEffect } from "react"; 4 | 5 | export default function Error({ 6 | error, 7 | }: { 8 | error: Error & { digest?: string }; 9 | reset: () => void; 10 | }) { 11 | useEffect(() => { 12 | // Log the error to an error reporting service 13 | console.error(error); 14 | }, [error]); 15 | 16 | return ( 17 |
18 |

Error

19 | 20 |
21 |

22 | Badge Engine encountered an unhandled error. 23 |

24 |

25 | Verify that you have the correct URL, or contact the site administrator for assistance. 26 |

27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/[credentialId]/CreatorLink.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useParams } from "next/navigation"; 5 | 6 | export default function CreatorProfileLink() { 7 | const params = useParams(); 8 | 9 | if (!params.id) return null; 10 | 11 | return ( 12 | 13 | View Issuer 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/[credentialId]/award/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "~/trpc/react"; 4 | import { AwardFormProvider } from "~/providers/award-form-provider"; 5 | import { WithCredential } from "~/providers/award-provider"; 6 | import { TRPCReactProvider } from "~/trpc/react"; 7 | import { useParams } from "next/navigation"; 8 | 9 | export default function AwardLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | 21 | function AwardContent({ children }: { children: React.ReactNode }) { 22 | const params = useParams(); 23 | const { data: credential, isLoading } = api.credential.find.useQuery( 24 | { docId: params.credentialId as string }, 25 | { enabled: !!params.credentialId } 26 | ); 27 | 28 | if (isLoading || !credential) { 29 | return ( 30 |
31 |
32 |
33 |
34 |
35 | ); 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/[credentialId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import CreatorLink from "./CreatorLink" 3 | import emptyBadgeSVG from "~/assets/illustrations/badge-empty.svg"; 4 | 5 | export default function CredentialNotFound() { 6 | return ( 7 |
8 |

Credential Not Found

9 | 10 | 11 |
12 |

13 | The credential you are attempting to view could not be 14 | found. 15 |

16 |

17 | Verify that you have the correct URL, or search the issuing organization's achievements. 18 |

19 |
20 | 21 |
22 | ); 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/[credentialId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { api } from "~/trpc/server"; 3 | import Credential from "~/components/Credential/Credential"; 4 | import { isTRPCClientError } from "~/lib/error"; 5 | 6 | export default async function AchievementDetails({ 7 | params, 8 | }: { 9 | params: { credentialId: string }; 10 | }) { 11 | try { 12 | const credential = await api.credential.find.query({ 13 | docId: params.credentialId, 14 | }); 15 | 16 | return ( 17 |
18 | 19 |
20 | ); 21 | } catch (cause) { 22 | if (isTRPCClientError(cause)) { 23 | if (cause.message === "Record not found") return notFound(); 24 | 25 | throw cause; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/new/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AchievementStoreProvider } from "~/providers/achievement-form-provider"; 4 | 5 | export default function CreateAchievementLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/credentials/new/preview/page.tsx: -------------------------------------------------------------------------------- 1 | import CredentialPreview, { CredentialPreviewActions } from "~/components/Credential/CredentialPreview"; 2 | import Icon from "~/components/icon"; 3 | 4 | export default function CredentialFormPreview() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |

13 | Previewing the achievement public page. 14 |

15 |

16 | If everything looks good, you can go ahead and create the 17 | achievement. 18 |

19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from "react"; 2 | import { notFound } from "next/navigation"; 3 | import { api } from "~/trpc/server"; 4 | import { WithIssuer } from "~/providers/issuer-provider"; 5 | 6 | export default async function IssuerDetailLayout({ 7 | params, 8 | children, 9 | }: { 10 | params: { id: string }; 11 | children: ReactNode; 12 | }) { 13 | const issuer = await api.issuer.find.query({ docId: params.id }); 14 | 15 | if (!issuer) return notFound(); 16 | 17 | return {children}; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { api } from "~/trpc/server"; 4 | import CredentialCard from "~/components/CredentialCard"; 5 | import Icon from "~/components/icon"; 6 | import emptyBadgeSVG from "~/assets/illustrations/badge-empty.svg"; 7 | import { IssuerDetailSidebarLayout } from "~/components/issuer"; 8 | import BreadcrumbNavigation from "~/components/global/breadcrumb"; 9 | import { type ListingParams, ListView } from "~/components/list-view"; 10 | 11 | export default async function CredentialsList({ 12 | params, 13 | searchParams, 14 | }: { 15 | params: { id: string }; 16 | searchParams: ListingParams; 17 | }) { 18 | const { s, page } = searchParams; 19 | const issuer = await api.issuer.find.query({ docId: params.id }); 20 | 21 | if (!issuer) return null; 22 | 23 | const breadcrumbs = [ 24 | 25 | {issuer.name ?? "Profile"} 26 | , 27 | ]; 28 | 29 | const limit = 4; 30 | 31 | const [total, count, achievements] = await api.credential.index.query({ 32 | issuerId: params.id, 33 | s, 34 | page, 35 | limit, 36 | }); 37 | 38 | const Achievements = () => 39 | achievements.map((credential) => ( 40 | 41 | )); 42 | 43 | const searchPlaceholder = `Search in ${total} achievement${total > 1 ? "s" : ""}`; 44 | 45 | return ( 46 | <> 47 | 48 | 49 | {(total === 0) ? ( 50 | 51 | ) : ( 52 |
53 |
54 |

Achievements

55 | 59 | 60 | Create New 61 | 62 |
63 | 64 | 65 | 66 |
67 | )} 68 |
69 | 70 | ); 71 | } 72 | 73 | function NoBadges({ issuerId }: { issuerId: string }) { 74 | return ( 75 | <> 76 |

77 | Achievements 78 |

79 |
80 | 81 |
82 |

No achievements here yet

83 |

84 | You can create a new one or import from a local file. 85 |

86 |
87 |
88 | 89 | 90 | Create New 91 | 92 |
93 |
94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/app/issuers/[id]/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useContext } from "react"; 4 | import { IssuerContext } from "~/providers/issuer-provider"; 5 | import { IssuingOrganizationForm } from "~/components/forms/issuing-organization"; 6 | import { IssuerDetailSidebarLayout } from "~/components/issuer"; 7 | 8 | export default function Issuer() { 9 | const issuer = useContext(IssuerContext); 10 | 11 | if (!issuer) return ""; 12 | 13 | return ( 14 | 15 |

Org Settings

16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/issuers/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import WithAuthorization from "~/components/global/auth-guard"; 3 | 4 | export default async function IssuerLayout({ 5 | children, 6 | }: { 7 | children: ReactNode; 8 | }) { 9 | return {children}; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/issuers/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { IssuingOrganizationForm } from "~/components/forms/issuing-organization"; 2 | import BreadcrumbNavigation from "~/components/global/breadcrumb"; 3 | 4 | export default function NewIssuer() { 5 | const breadcrumbs = [ 6 |

Add New Issuing Organization

7 | ] 8 | 9 | return ( 10 | <> 11 | 12 |
13 |

14 | Add New Issuing Organization 15 |

16 | 17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/issuers/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import instituteEmpty from "~/assets/illustrations/institute-empty.svg"; 4 | 5 | export default function IssuerNotFound() { 6 | return ( 7 |
8 |

Issuer Not Found

9 | 10 | 11 |
12 |

13 | The issuing organization you are attempting to view could not be 14 | found. 15 |

16 |

17 | Verify that you have the correct URL, or search for the organization 18 | on the 19 | 20 | issuers page 21 | 22 | . 23 |

24 |
25 | 26 | 27 | Return to issuers 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next"; 2 | import { DashboardHeader } from "~/components/global/header"; 3 | import { DashboardFooter } from "~/components/global/footer"; 4 | import { NotificationProvider } from "~/providers/notification-provider"; 5 | import { Notifications } from "~/components/global/notifications"; 6 | import "~/styles/globals.css"; 7 | import { museo } from "~/assets/fonts"; 8 | 9 | export const metadata: Metadata = { 10 | title: "Badge Engine", 11 | description: "A solution for issuing verifiable credentials.", 12 | icons: [{ rel: "icon", url: "/badge-engine-icon.svg" }], 13 | robots: { 14 | index: false, 15 | follow: false, 16 | googleBot: { 17 | index: false, 18 | follow: false, 19 | nositelinkssearchbox: true, 20 | }, 21 | }, 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: { 27 | children: React.ReactNode; 28 | }) { 29 | return ( 30 | 31 | 32 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { NotImplemented } from "~/components/error"; 2 | 3 | export default function Error404() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { authOptions } from "~/server/auth"; 4 | import Login from "~/components/login"; 5 | import Logout from "~/components/logout"; 6 | import badgeEngineByDigitalPromise from "public/alt-badge-engine-logo-color.svg"; 7 | 8 | export default async function Welcome() { 9 | const session = await getServerSession(authOptions); 10 | 11 | return ( 12 |
13 |
14 | 21 | 22 |
23 |

Welcome to Badge Engine!

24 | {session === null && ( 25 |

26 | Please sign in to see what we've got for the first release. 27 |

28 | )} 29 |
30 | 31 | {session ? ( 32 | Sign Out 33 | ) : ( 34 | 35 | Sign In 36 | 37 | )} 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/assets/fonts/Digital-Promise-Icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/Digital-Promise-Icons.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Digital-Promise-Icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/Digital-Promise-Icons.woff -------------------------------------------------------------------------------- /src/assets/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | 3 | export const museo = localFont({ 4 | variable: "--font-museo", 5 | src: [ 6 | { 7 | path: "./museo-300-normal.woff2", 8 | weight: "300", 9 | style: "normal", 10 | }, 11 | { 12 | path: "./museo-500-normal.woff2", 13 | weight: "500", 14 | style: "normal", 15 | }, 16 | { 17 | path: "./museo-700-normal.woff2", 18 | weight: "700", 19 | style: "normal", 20 | }, 21 | { 22 | path: "./museo-900-normal.woff2", 23 | weight: "900", 24 | style: "normal", 25 | }, 26 | { 27 | path: "./museo-300-italic.woff2", 28 | weight: "300", 29 | style: "italic", 30 | }, 31 | { 32 | path: "./museo-500-italic.woff2", 33 | weight: "500", 34 | style: "italic", 35 | }, 36 | { 37 | path: "./museo-700-italic.woff2", 38 | weight: "700", 39 | style: "italic", 40 | }, 41 | { 42 | path: "./museo-900-italic.woff2", 43 | weight: "900", 44 | style: "italic", 45 | }, 46 | ], 47 | }); 48 | 49 | export const dpgIcons = localFont({ 50 | variable: "--dpg-icons", 51 | src: "../../../node_modules/@digitalpromise/icons/dist/dpg-icons.woff2", 52 | }); 53 | -------------------------------------------------------------------------------- /src/assets/fonts/museo-300-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-300-italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-300-normal.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-500-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-500-italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-500-normal.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-700-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-700-italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-700-normal.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-900-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-900-italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/museo-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-promise/badge-engine/bad61f522bc91e370e9b90963c225a86611e981c/src/assets/fonts/museo-900-normal.woff2 -------------------------------------------------------------------------------- /src/assets/illustrations/badge-empty.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/illustrations/crow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/illustrations/institute-empty.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/assets/illustrations/lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Credential/AssessmentExtension.tsx: -------------------------------------------------------------------------------- 1 | import Markdown from "react-markdown"; 2 | import remarkGfm from "remark-gfm"; 3 | 4 | interface AssessmentExtensionProps { 5 | supportingResearchAndRationale: string | null; 6 | resources: string | null; 7 | } 8 | 9 | export default function AssessmentExtension(props: AssessmentExtensionProps) { 10 | const { supportingResearchAndRationale, resources } = props; 11 | return ( 12 |
13 |

14 | Supporting Information 15 |

16 |
17 | {supportingResearchAndRationale && ( 18 |
19 |

20 | Supporting Research and Rationale 21 |

22 | 23 | {supportingResearchAndRationale} 24 | 25 |
26 | )} 27 | 28 | {resources && ( 29 |
30 |

Resources

31 | 32 | {resources} 33 | 34 |
35 | )} 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Credential/AwardHistory.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { api } from "~/trpc/react"; 5 | 6 | import type { Credential, AwardHistory as IAwardHistory } from "~/trpc/shared"; 7 | import Icon from "../icon"; 8 | 9 | export const AwardHistory = ({ credential }: { credential: Credential }) => { 10 | const [query, setQuery] = useState(""); 11 | const { data: awards } = api.award.index.useQuery({ 12 | credentialId: credential.docId, 13 | query, 14 | }); 15 | 16 | return ( 17 |
18 |
e.preventDefault()}> 19 | 22 |
23 | 24 | 25 | 26 | setQuery(e.target.value)} 32 | /> 33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {awards?.map((award, index) => ( 45 | 46 | ))} 47 | 48 |
Recipient NameStatusAwarded on
49 |
50 | ); 51 | }; 52 | 53 | const AwardListItem = ({ award }: { award: IAwardHistory[number] }) => { 54 | const { 55 | credentialSubject: { profile }, 56 | awardedDate, 57 | } = award; 58 | 59 | const name = 60 | profile?.name ?? 61 | ([profile?.givenName, profile?.familyName] 62 | .filter((n) => n) 63 | .join(" ") 64 | .trim() || 65 | "Anonymous User"); 66 | 67 | return ( 68 | 69 | 70 |

{name}

71 | {profile?.email &&

{profile.email}

} 72 | 73 | 74 | 75 |

79 | {award.claimed ? "Claimed" : "Awaiting Claim"} 80 |

81 | 82 | 83 | 84 | {awardedDate && ( 85 |

86 | {new Date(awardedDate).toLocaleDateString(undefined, { 87 | month: "short", 88 | day: "numeric", 89 | })} 90 |

91 | )} 92 | 93 | 94 | ); 95 | }; 96 | 97 | export default AwardHistory; 98 | -------------------------------------------------------------------------------- /src/components/Credential/ConfirmAndCreate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { submitCredential } from "~/lib/submit"; 5 | import { useAchievementStore } from "~/providers/achievement-form-provider"; 6 | import { useIssuerContext } from "~/providers/issuer-provider"; 7 | import { useState } from "react"; 8 | import { useNotifications } from "~/providers/notification-provider"; 9 | 10 | export default function ConfirmAndCreate({ 11 | className, 12 | }: { 13 | className?: string; 14 | }) { 15 | const { docId: issuerId } = useIssuerContext(); 16 | const { form, isExtrasComplete, setIsExtrasComplete } = useAchievementStore(); 17 | const { notify } = useNotifications(); 18 | const router = useRouter(); 19 | const [isSubmitting, setIsSubmitting] = useState(false); 20 | const [isSubmitted, setIsSubmitted] = useState(false); 21 | 22 | const handleSubmit = async () => { 23 | if (isSubmitting || isSubmitted) return; 24 | setIsSubmitting(true); 25 | if (isExtrasComplete) { 26 | setIsExtrasComplete(isExtrasComplete); 27 | } 28 | const newAchievement = await submitCredential(issuerId, form); 29 | 30 | if (newAchievement) { 31 | notify({ 32 | type: "success", 33 | message: `Successfully added credential "${newAchievement.name}"`, 34 | }); 35 | 36 | router.push(`/issuers/${issuerId}`); 37 | setIsSubmitted(true); 38 | } 39 | setIsSubmitting(false); 40 | }; 41 | 42 | return ( 43 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Credential/Credential.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import type { Credential } from "~/trpc/shared"; 3 | import CredentialDetails from "~/components/Credential/CredentialDetails"; 4 | import { TabList, type TabListProps } from "~/components/Tabs/TabList"; 5 | import AwardHistory from "~/components/Credential/AwardHistory"; 6 | import { TRPCReactProvider } from "~/trpc/react"; 7 | import Link from "next/link"; 8 | import Icon from "../icon"; 9 | 10 | export default function Credential({ 11 | preview, 12 | credential, 13 | }: { 14 | preview?: boolean; 15 | credential: Credential; 16 | }) { 17 | const tabs: TabListProps["tabs"] = [ 18 | { 19 | label: "Achievement Details", 20 | id: "achievement-details", 21 | content: , 22 | }, 23 | ]; 24 | 25 | if (preview !== true) 26 | tabs.push({ 27 | label: "Award History", 28 | id: "award-history", 29 | content: ( 30 | 31 | 32 | 33 | ), 34 | }); 35 | 36 | return ( 37 |
38 | 43 |
44 |
45 |
46 |

47 | {credential.name} 48 |

49 | {!preview && ( 50 | 55 | Award 56 | 57 | )} 58 |
59 | 60 |

{credential.description}

61 |
62 | 63 | 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Credential/CredentialDetails.tsx: -------------------------------------------------------------------------------- 1 | import Markdown from "react-markdown"; 2 | import remarkGfm from "remark-gfm"; 3 | import type { Credential } from "~/trpc/shared"; 4 | import { capitalize } from "~/util"; 5 | import TagList from "~/components/Credential/TagList"; 6 | import ResultDescription from "~/components/Credential/ResultDescription"; 7 | import AssessmentExtension from "~/components/Credential/AssessmentExtension"; 8 | 9 | export const CredentialDetails = ({ 10 | credential, 11 | }: { 12 | credential: Credential; 13 | }) => { 14 | let assessmentExtension = undefined; 15 | 16 | ext_check: for (const ext of credential.extensions) { 17 | for (const extension of ext.assessmentExtensions) { 18 | assessmentExtension = extension; 19 | break ext_check; 20 | } 21 | } 22 | return ( 23 |
24 |
25 |

26 | Achievement Type 27 |

28 | 29 |
30 | 31 | {credential.criteria.narrative && ( 32 |
33 |

34 | Criteria 35 |

36 | 37 | 38 | {credential.criteria?.narrative || ""} 39 | 40 |
41 | )} 42 | 43 | {credential.resultDescription.length > 0 && ( 44 |
45 |

46 | Assessment 47 |

48 | 49 |
50 | {credential.resultDescription.map((assessment, index) => ( 51 | 52 | ))} 53 |
54 |
55 | )} 56 | 57 | {credential.tag.length > 0 && ( 58 |
59 |

60 | Tags 61 |

62 | 63 |
64 | )} 65 | 66 | {assessmentExtension && } 67 |
68 | ); 69 | }; 70 | 71 | export default CredentialDetails; 72 | -------------------------------------------------------------------------------- /src/components/Credential/CredentialPreview.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | "use client"; 5 | 6 | import { useRouter } from "next/navigation"; 7 | import { PreviewCredentialSchema } from "~/server/api/schemas/credential.schema"; 8 | import { useAchievementStore } from "~/providers/achievement-form-provider"; 9 | import Credential from "./Credential"; 10 | import { useIssuerContext } from "~/providers/issuer-provider"; 11 | import ConfirmAndCreate from "./ConfirmAndCreate"; 12 | import { useNotifications } from "~/providers/notification-provider"; 13 | 14 | export default function CredentialPreview() { 15 | const { docId: issuerId } = useIssuerContext(); 16 | const { form } = useAchievementStore(); 17 | const { notify } = useNotifications(); 18 | const router = useRouter(); 19 | try { 20 | const credential = PreviewCredentialSchema.parse(form); 21 | return ; 22 | } catch (_error) { 23 | notify({ 24 | type: "error", 25 | message: 26 | "Unable to generate preview with current form. Check that all required fields have been completed.", 27 | }); 28 | 29 | router.replace(`/issuers/${issuerId}/credentials/new/extras`); 30 | } 31 | 32 | return null; 33 | } 34 | 35 | export function CredentialPreviewActions() { 36 | const router = useRouter(); 37 | 38 | return ( 39 |
40 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Credential/ResultDescription.tsx: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | import { pascalCaseToSpaced } from "~/util"; 3 | import Rubric from "./Rubric"; 4 | import remarkGfm from "remark-gfm"; 5 | import Markdown from "react-markdown"; 6 | 7 | type Assessment = Prisma.ResultDescriptionGetPayload<{ 8 | select: { 9 | name: true; 10 | uiPlacement: true; 11 | rubricCriterionLevel: true; 12 | }; 13 | }>; 14 | 15 | export default function ResultDescription({ 16 | assessment: { name, uiPlacement: type, rubricCriterionLevel }, 17 | }: { 18 | assessment: Assessment; 19 | }) { 20 | return ( 21 |
22 |

{pascalCaseToSpaced(type)}

23 | 24 | {name} 25 | 26 | 27 | {rubricCriterionLevel.length > 0 && ( 28 | 29 | )} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Credential/Rubric.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { RubricCriterionLevel } from "@prisma/client"; 4 | import { useState } from "react"; 5 | import Icon from "~/components/icon"; 6 | 7 | export default function Rubric({ levels }: { levels: RubricCriterionLevel[] }) { 8 | const [showRubric, setShowRubric] = useState(false); 9 | if (levels.length === 0) return null; 10 | 11 | return ( 12 | 39 | ); 40 | } 41 | 42 | function RubricCriterionRow({ 43 | criterion: { level, description }, 44 | }: { 45 | criterion: RubricCriterionLevel; 46 | }) { 47 | return ( 48 | 49 | {level} 50 | {description} 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/Credential/TagList.tsx: -------------------------------------------------------------------------------- 1 | export default function TagList({ tags }: { tags: string[] }) { 2 | return ( 3 |
    4 | {tags 5 | .filter((t) => t) 6 | .map((t, i) => ( 7 |
  • 11 | {t} 12 |
  • 13 | ))} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/CredentialCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { type RouterOutputs } from "~/trpc/shared"; 3 | import compassSVG from "~/assets/illustrations/compass.svg"; 4 | import Link from "next/link"; 5 | 6 | export default function CredentialCard({ 7 | credential, 8 | }: { 9 | credential: RouterOutputs["credential"]["index"][2][number]; 10 | }) { 11 | let badgeImgSrc = compassSVG as string; 12 | 13 | if (credential.image?.id) { 14 | badgeImgSrc = credential.image.id; 15 | } 16 | 17 | return ( 18 |
19 | 27 |
28 |

29 | {credential.name} 30 |

31 |

{credential.description}

32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Tabs/TabList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, type ReactNode } from "react"; 4 | 5 | export interface TabListProps { 6 | tabs: { 7 | id: string; 8 | label: string; 9 | content: ReactNode; 10 | }[]; 11 | } 12 | 13 | export const TabList = ({ tabs }: TabListProps) => { 14 | const [selectedId, setSelectedId] = useState(tabs[0]?.id); 15 | 16 | if (tabs.length <= 1) return tabs.map((tab) => tab.content); 17 | 18 | const activeTab = tabs.find((tab) => tab.id === selectedId); 19 | 20 | return ( 21 |
22 | 39 | 40 | {activeTab?.content} 41 |
42 | ); 43 | }; 44 | 45 | export default TabList; 46 | -------------------------------------------------------------------------------- /src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef, cloneElement as clone, type ReactElement, type ReactNode } from "react"; 4 | import { createPortal } from "react-dom"; 5 | 6 | export function Dialog({ trigger, children } : { trigger: ReactElement, children: ReactNode }) { 7 | const dialogRef = useRef(null) 8 | const triggerLocal = clone(trigger, { onClick: () => { 9 | dialogRef.current?.showModal() 10 | }}) 11 | 12 | return ( 13 | <> 14 | {triggerLocal} 15 | 16 | {createPortal( 17 | 18 | 19 | {children} 20 | , 21 | document.body 22 | )} 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /src/components/error.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import edtechSVG from "~/assets/illustrations/institute-empty.svg"; 4 | 5 | export function NotImplemented() { 6 | return ( 7 |
8 | 9 |
10 |

Coming soon...

11 |

We're working hard on designing and developing this page.

12 |
13 | 14 | Back to Homepage 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/errors/403.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import lockSVG from "~/assets/illustrations/lock.svg"; 5 | 6 | export default function Forbidden() { 7 | return ( 8 |
9 | 16 |
17 |

No access to this page

18 |

Contact the site administrator for access.

19 |
20 | 21 | Back to Badge Engine Homepage 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/forms/ForwardRefEditor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { MDXEditorMethods, MDXEditorProps } from '@mdxeditor/editor' 4 | import dynamic from 'next/dynamic' 5 | import { forwardRef } from 'react' 6 | 7 | // This is the only place InitializedMDXEditor is imported directly. 8 | const Editor = dynamic(() => import('./InitializedMDXEditor'), { 9 | // Make sure we turn SSR off 10 | ssr: false 11 | }) 12 | 13 | // This is what is imported by other components. Pre-initialized with plugins, and ready 14 | // to accept other props, including a ref. 15 | export const ForwardRefEditor = forwardRef((props, ref) => ) 16 | 17 | // TS complains without the following line 18 | ForwardRefEditor.displayName = 'ForwardRefEditor' -------------------------------------------------------------------------------- /src/components/forms/InitializedMDXEditor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { ForwardedRef } from "react"; 3 | import { 4 | headingsPlugin, 5 | listsPlugin, 6 | thematicBreakPlugin, 7 | markdownShortcutPlugin, 8 | BoldItalicUnderlineToggles, 9 | toolbarPlugin, 10 | BlockTypeSelect, 11 | linkDialogPlugin, 12 | MDXEditor, 13 | type MDXEditorMethods, 14 | type MDXEditorProps, 15 | CreateLink, 16 | ListsToggle, 17 | linkPlugin, 18 | } from "@mdxeditor/editor"; 19 | import "@mdxeditor/editor/style.css"; 20 | import "~/styles/mdxeditor.css"; 21 | 22 | export default function InitializedMDXEditor({ 23 | editorRef, 24 | error, 25 | ...props 26 | }: { editorRef: ForwardedRef | null } & MDXEditorProps & { 27 | error: boolean; 28 | }) { 29 | return ( 30 |
33 | ( 44 |
45 |
46 |
47 | 48 | 49 | 50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 | ), 63 | }), 64 | ]} 65 | {...props} 66 | ref={editorRef} 67 | /> 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/forms/achievement.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ReactNode } from "react"; 4 | import { CredentialFormNav } from "~/components/forms/credential-form-nav"; 5 | import "~/styles/forms.css"; 6 | import { useIssuerContext } from "~/providers/issuer-provider"; 7 | import BreadcrumbNavigation from "~/components/global/breadcrumb"; 8 | import Link from "next/link"; 9 | 10 | export function AchievementFormSection({ children }: { children: ReactNode }) { 11 | const issuer = useIssuerContext(); 12 | const breadcrumbs = [ 13 | 14 | {issuer.name ?? "Profile"} 15 | , 16 |

Create New Achievement

, 17 | ]; 18 | 19 | return ( 20 | <> 21 | 22 |
23 |
24 |

Create New Achievement

25 | 26 |
27 | {children} 28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/forms/award-form-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { useIssuerContext } from "../../providers/issuer-provider"; 7 | import { AWARD_FORM_STEPS } from "~/lib/constants"; 8 | import { AwardFormProvider, useAwardFormStore } from "~/providers/award-form-provider"; 9 | 10 | export function AwardFormNav() { 11 | const pathname = usePathname(); 12 | const { docId: issuerId } = useIssuerContext(); 13 | const { currentStep, setStep, completedSteps } = useAwardFormStore(); 14 | const baseUrl = `/issuers/${issuerId}/credentials/new`; 15 | 16 | useEffect(() => { 17 | const compare = pathname.split("/").pop(); 18 | 19 | if (compare === "new") { 20 | setStep(0); 21 | } else { 22 | for (const [index, step] of AWARD_FORM_STEPS.entries()) { 23 | const { path } = step; 24 | if (path === compare) { 25 | setStep(index); 26 | break; 27 | } 28 | } 29 | } 30 | }, [pathname, setStep]); 31 | 32 | return ( 33 | 34 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/forms/award.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ReactNode } from "react"; 4 | // import { AwardFormNav } from "~/components/forms/award-form-nav"; 5 | import "~/styles/forms.css"; 6 | import { useIssuerContext } from "~/providers/issuer-provider"; 7 | import { useCredentialContext } from "~/providers/award-provider"; 8 | import BreadcrumbNavigation from "~/components/global/breadcrumb"; 9 | import Link from "next/link"; 10 | 11 | export function AwardFormSection({ children }: { children: ReactNode }) { 12 | const issuer = useIssuerContext(); 13 | const credential = useCredentialContext(); 14 | 15 | const breadcrumbs = [ 16 | 17 | {issuer.name ?? "Profile"} 18 | , 19 | 20 | {credential.name} 21 | , 22 | ]; 23 | 24 | return ( 25 | <> 26 | 27 |
28 |
29 |

Award {credential.name}

30 | {/* */} 31 |
32 | {children} 33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/forms/credential-form-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { useIssuerContext } from "../../providers/issuer-provider"; 7 | import { useAchievementStore } from "~/providers/achievement-form-provider"; 8 | import { CREDENTIAL_FORM_STEPS } from "~/lib/constants"; 9 | 10 | export function CredentialFormNav() { 11 | const pathname = usePathname(); 12 | const { docId: issuerId } = useIssuerContext(); 13 | const { currentStep, setStep, completedSteps } = useAchievementStore(); 14 | const baseUrl = `/issuers/${issuerId}/credentials/new`; 15 | 16 | useEffect(() => { 17 | const compare = pathname.split("/").pop(); 18 | 19 | if (compare === "new") { 20 | setStep(0); 21 | } else { 22 | for (const [index, step] of CREDENTIAL_FORM_STEPS.entries()) { 23 | const { path } = step; 24 | if (path === compare) { 25 | setStep(index); 26 | break; 27 | } 28 | } 29 | } 30 | }, [pathname, setStep]); 31 | 32 | return ( 33 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/forms/errors.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { IssuerFormState } from "~/server/actions/create-issuer"; 4 | import type { FieldError, FieldErrorsImpl, Merge } from 'react-hook-form'; 5 | import type { AchievementFormState } from "~/app/issuers/[id]/credentials/new/page"; 6 | 7 | type FormErrorMessage = string | FieldError | Merge>> | undefined; 8 | 9 | export const useFormErrors = (state: IssuerFormState | AchievementFormState | null) => { 10 | const hasFieldError = (fieldName: string) => { 11 | return state?.errors?.some(error => error.field?.includes(fieldName)); 12 | }; 13 | 14 | const getFieldErrorMessage = (fieldName: string) => { 15 | const error = state?.errors?.find(error => error.field?.includes(fieldName)); 16 | if (error?.message?.includes(error.field ?? "")) { 17 | return error.message; 18 | } 19 | return error?.message ? `${error.message} ${error.field ?? ""}`.trim() : ""; 20 | }; 21 | 22 | const getGeneralErrorMessages = () => { 23 | return state?.errors?.filter(error => !error.field).map(error => error.message) ?? []; 24 | }; 25 | 26 | return { hasFieldError, getFieldErrorMessage, getGeneralErrorMessages }; 27 | }; 28 | 29 | export function FormError({ message }: { message?: FormErrorMessage }) { 30 | const formattedMessage = typeof message === 'string' ? message.replace(/url/gi, 'URL') : ''; 31 | 32 | return ( 33 |
34 |

{formattedMessage}

35 |
36 | ) 37 | } -------------------------------------------------------------------------------- /src/components/forms/expiry.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | export default function ExpiryInput({ 6 | name, 7 | _defaultValue 8 | } : { name: string, _defaultValue?: object }) { 9 | const [validityType, setValidityType] = useState("neverExpires") 10 | const validities = [ 11 | ["neverExpires", "It will never expire"], 12 | ["dateRange", "In a specific date range"], 13 | ["specifiedPeriod", "After a given period of time"] 14 | ] 15 | 16 | return ( 17 |
18 |
19 | {validities.map(i => { 20 | const [value, label] = i 21 | const id = `${name}_${value}` 22 | 23 | return ( 24 |
25 | setValidityType(value!)} defaultChecked={validityType === value} /> 26 | 27 |
28 | ) 29 | })} 30 | 31 | {validityType === "dateRange" && ( 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | )} 43 | 44 | {validityType === "specifiedPeriod" && ( 45 |
46 | 47 |
48 | 49 | 56 |
57 |
58 | )} 59 |
60 |
61 | ) 62 | } -------------------------------------------------------------------------------- /src/components/forms/list.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | "use client"; 3 | import { useRef, useState, useEffect, useCallback } from "react"; 4 | import Icon from "~/components/icon"; 5 | 6 | export interface ListInputProps { 7 | onChange: (value: string[]) => void; 8 | defaultValue?: string[]; 9 | } 10 | 11 | export default function ListInput({ onChange, defaultValue }: ListInputProps) { 12 | const [items, setItems] = useState(new Set(defaultValue)); 13 | const inputRef = useRef(null); 14 | 15 | const handleUpdate = (items: Set) => { 16 | onChange(Array.from(items)); 17 | setItems(new Set(items)); 18 | }; 19 | 20 | const addItems = () => { 21 | if (inputRef.current) { 22 | const values = inputRef.current.value.split(",").map((t) => t.trim()); 23 | for (const value of values) { 24 | if (value) items.add(value); 25 | } 26 | handleUpdate(items); 27 | inputRef.current.value = ""; 28 | } 29 | }; 30 | 31 | const removeItem = (item: string) => { 32 | if (items.delete(item)) { 33 | handleUpdate(items); 34 | } 35 | }; 36 | 37 | return ( 38 | <> 39 |
40 | e.stopPropagation()} 46 | onKeyUp={(e) => { 47 | if (e.key === "Enter") addItems(); 48 | }} 49 | > 50 | 57 |
58 |
    59 | {[...items].map((i) => ( 60 |
  • 64 | {i}{" "} 65 | 72 |
  • 73 | ))} 74 |
75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/forms/select-dropdown.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | "use client"; 5 | 6 | import Select, { 7 | components, 8 | type GroupBase, 9 | type DropdownIndicatorProps, 10 | type Props, 11 | type CSSObjectWithLabel, 12 | ControlProps, 13 | } from "react-select"; 14 | import Icon from "~/components/icon"; 15 | 16 | export function transformEnum(enumProp: any) { 17 | const enumValues = Object.values(enumProp); 18 | const enumObject = enumValues.map((option) => { 19 | return { value: option as string, label: option as string }; 20 | }); 21 | 22 | return enumObject; 23 | } 24 | 25 | export default function DropdownSelection< 26 | Option, 27 | IsMulti extends boolean = false, 28 | Group extends GroupBase