├── bin └── .keep ├── web └── ui │ ├── cypress.json │ ├── .eslintignore │ ├── .gitignore │ ├── .prettierrc │ ├── src │ ├── components │ │ ├── Button │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── Button.tsx │ │ ├── Portal │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── Soon │ │ │ ├── index.ts │ │ │ └── Soon.tsx │ │ ├── Emoji │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── Emoji.tsx │ │ ├── ColorDisplay │ │ │ ├── index.ts │ │ │ ├── ColorDisplay.tsx │ │ │ └── __tests__ │ │ │ │ └── ColorDisplay.test.tsx │ │ ├── Search │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── UserCard │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── AuthError │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ └── AuthError.test.tsx.snap │ │ │ │ └── AuthError.test.tsx │ │ ├── Loader │ │ │ └── index.ts │ │ ├── UserProfile │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── NewFeaturePing │ │ │ ├── index.ts │ │ │ └── NewFeaturePing.tsx │ │ ├── Notice │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── RemoteNotices.tsx │ │ ├── Avatar │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── Notification │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── Tooltip │ │ │ ├── index.tsx │ │ │ ├── types.d.ts │ │ │ └── __tests__ │ │ │ │ ├── Tooltip.test.tsx │ │ │ │ └── __snapshots__ │ │ │ │ └── Tooltip.test.tsx.snap │ │ ├── ConditionalWrapper │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── ConditionalWrapper.tsx │ │ ├── Sidebar │ │ │ ├── index.tsx │ │ │ ├── SidebarContext.tsx │ │ │ ├── hooks.tsx │ │ │ └── SidebarProvider.tsx │ │ ├── Name │ │ │ ├── index.ts │ │ │ ├── utils.ts │ │ │ ├── types.d.ts │ │ │ └── Name.tsx │ │ ├── UserPopup │ │ │ └── index.ts │ │ ├── Form │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ ├── __tests__ │ │ │ │ ├── TextInput.test.tsx │ │ │ │ ├── ColorInput.test.tsx │ │ │ │ └── SelectInput.test.tsx │ │ │ ├── ColorInput.tsx │ │ │ ├── Switch.tsx │ │ │ ├── TextInput.tsx │ │ │ └── FileInput.tsx │ │ ├── ClusterMap │ │ │ ├── index.ts │ │ │ └── types.d.ts │ │ ├── Badge │ │ │ ├── index.ts │ │ │ ├── __tests__ │ │ │ │ ├── data.test.ts │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Badge.test.tsx.snap │ │ │ ├── Badgy.tsx │ │ │ ├── utils.ts │ │ │ ├── FlagBadge.tsx │ │ │ └── types.d.ts │ │ └── Sponsors │ │ │ └── index.tsx │ ├── types │ │ ├── modules.d.ts │ │ ├── next-env.d.ts │ │ ├── next.d.ts │ │ ├── globals.d.ts │ │ └── utils.d.ts │ ├── __mocks__ │ │ ├── styleMock.ts │ │ ├── fileMock.ts │ │ └── framerMotionMock.tsx │ ├── lib │ │ ├── GraphqlAdapter │ │ │ └── index.ts │ │ ├── jsonParser.ts │ │ ├── clustersMap │ │ │ ├── index.ts │ │ │ ├── types.generated.d.ts │ │ │ ├── utils.ts │ │ │ ├── countryEmoji.ts │ │ │ └── campuses.generated.ts │ │ ├── useIsomorphicLayoutEffect.ts │ │ ├── config.ts │ │ ├── useEventCallback.ts │ │ ├── storageKeys.ts │ │ ├── useKeyDown.tsx │ │ ├── searchEngine.ts │ │ └── useDebounce.ts │ ├── pages │ │ ├── 404.tsx │ │ ├── friends │ │ │ └── index.tsx │ │ ├── settings │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── feed │ │ │ └── index.tsx │ │ ├── statistics │ │ │ └── index.tsx │ │ ├── api │ │ │ └── auth │ │ │ │ └── [...nextauth].ts │ │ ├── _document.tsx │ │ ├── _app.tsx │ │ └── clusters │ │ │ └── index.tsx │ ├── containers │ │ ├── friends │ │ │ └── index.ts │ │ └── settings │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ ├── SettingsCategory.tsx │ │ │ └── SettingsTable.tsx │ ├── styles │ │ └── globals.css │ ├── middleware.ts │ └── contexts │ │ └── types.d.ts │ ├── cypress │ ├── fixtures │ │ └── user.json │ ├── support │ │ ├── index.js │ │ └── commands.js │ └── plugins │ │ └── index.js │ ├── public │ └── assets │ │ ├── images │ │ ├── kappa.png │ │ ├── logo_bg_slate.png │ │ └── logo-42.svg │ │ └── favicon │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-70x70.png │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ └── site.webmanifest │ ├── postcss.config.js │ ├── .prettierignore │ ├── next-env.d.ts │ ├── jest.setup.ts │ ├── .env.example │ ├── .dockerignore │ ├── tailwind.config.js │ ├── graphqlcodegen.yml │ ├── sentry.edge.config.js │ ├── sentry.server.config.ts │ ├── sentry.client.config.ts │ ├── .eslintrc.js │ └── tsconfig.json ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── profile │ ├── PROGRESS.png │ └── logo-emoji.png ├── ISSUE_TEMPLATE │ ├── config.yml │ └── your_issue.yml ├── dependabot.yml └── workflows │ ├── devcontainers.yaml │ └── build.yaml ├── .gitattributes ├── deploy ├── stacks │ ├── cluster │ │ ├── configs │ │ │ └── monitoring │ │ │ │ ├── prometheus.rules │ │ │ │ ├── tempo-overrides.yaml │ │ │ │ ├── tempo.yaml │ │ │ │ └── loki.yaml │ │ ├── main.tf │ │ └── istio.tf │ ├── apps │ │ ├── configs │ │ │ ├── postgres │ │ │ │ └── init-database.sh │ │ │ ├── webhooked │ │ │ │ ├── github_template.tpl │ │ │ │ └── template.tpl │ │ │ └── stud42 │ │ │ │ └── stud42.yaml.tftpl │ │ ├── webhooked │ │ │ ├── locals.tf │ │ │ ├── variables.tf │ │ │ └── main.tf │ │ ├── apps.tf │ │ ├── s42 │ │ │ ├── configs.tf │ │ │ ├── main.tf │ │ │ ├── variables.tf │ │ │ └── locals.tf │ │ ├── main.tf │ │ └── variables.tf │ └── pre-cluster │ │ ├── main.tf │ │ └── kubernetes.tf └── modules │ ├── service │ ├── main.tf │ ├── locals.tf │ └── .terraform.lock.hcl │ ├── sealed-secrets │ ├── outputs.tf │ ├── main.tf │ └── variables.tf │ ├── istio │ ├── main.tf │ └── variables.tf │ └── cert-manager │ ├── certificates.tf │ ├── variables.tf │ └── issuers.tf ├── api └── graphs │ ├── scalars.graphqls │ └── directives.graphqls ├── .vscode └── settings.json ├── internal ├── pkg │ └── searchengine │ │ ├── searchengine.go │ │ └── meilisearch.go ├── models │ ├── gotype │ │ ├── settings.go │ │ ├── theme.go │ │ ├── account_type.go │ │ ├── cluster_map_avatar_size.go │ │ ├── following_group_kind.go │ │ ├── user_pronouns.go │ │ ├── user_flags.go │ │ └── notice_color.go │ ├── uuid.go │ ├── schema │ │ ├── follow.go │ │ ├── notice_user.go │ │ ├── campus.go │ │ ├── account.go │ │ ├── notice.go │ │ └── location.go │ ├── templates │ │ └── marshal_binary.go.tmpl │ ├── client.go │ └── uuid_test.go ├── discord │ └── utils.go ├── webhooks │ └── marshaler.go ├── auth │ └── struct.go └── api │ ├── resolver.go │ └── logging.go ├── cmd ├── serve.go ├── operations.go ├── jobs.go ├── crawler.go ├── webhooks.go └── auth.go ├── pkg ├── duoapi │ ├── endpoints.go │ ├── campus.go │ ├── time.go │ └── campus_user.go ├── utils │ ├── string.go │ ├── string_test.go │ ├── random_color.go │ ├── slug.go │ ├── slice.go │ └── random_color_test.go └── cache │ ├── option.go │ └── gql.go ├── pull_request_template.md ├── SECURITY.md ├── .devcontainer ├── Dockerfile ├── .env └── postStartCommand.sh ├── githooks └── commit-msg ├── Taskfile.yml ├── tools ├── seeds │ ├── seed_user.go │ └── main.go └── sealedSecret.py ├── LICENSE ├── .dockerignore └── .gitignore /bin/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/ui/cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [42atomys] 2 | -------------------------------------------------------------------------------- /web/ui/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | __generated__ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.jpg -text 4 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/configs/monitoring/prometheus.rules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/ui/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Sentry 3 | .sentryclirc 4 | coverage*/ 5 | -------------------------------------------------------------------------------- /web/ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /web/ui/src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export { Button } from './Button'; 2 | -------------------------------------------------------------------------------- /web/ui/src/components/Portal/index.ts: -------------------------------------------------------------------------------- 1 | export { Portal } from './Portal'; 2 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/configs/monitoring/tempo-overrides.yaml: -------------------------------------------------------------------------------- 1 | overrides: {} 2 | -------------------------------------------------------------------------------- /web/ui/src/components/Soon/index.ts: -------------------------------------------------------------------------------- 1 | export { Soon, Soon as default } from './Soon'; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Per default all source code is owned by @42atomys 2 | * @42atomys 3 | -------------------------------------------------------------------------------- /web/ui/src/components/Emoji/index.ts: -------------------------------------------------------------------------------- 1 | export { Emoji as default, Emoji } from './Emoji'; 2 | -------------------------------------------------------------------------------- /web/ui/src/types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.graphql'; 2 | declare module '*.gql'; 3 | -------------------------------------------------------------------------------- /api/graphs/scalars.graphqls: -------------------------------------------------------------------------------- 1 | scalar UUID 2 | scalar Any 3 | scalar Time 4 | scalar Cursor 5 | -------------------------------------------------------------------------------- /web/ui/src/__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | const styleMock = {}; 2 | 3 | export default styleMock; 4 | -------------------------------------------------------------------------------- /web/ui/src/components/ColorDisplay/index.ts: -------------------------------------------------------------------------------- 1 | export { ColorDisplay } from './ColorDisplay'; 2 | -------------------------------------------------------------------------------- /web/ui/src/components/Search/index.ts: -------------------------------------------------------------------------------- 1 | export { Search, Search as default } from './Search'; 2 | -------------------------------------------------------------------------------- /web/ui/src/components/UserCard/index.ts: -------------------------------------------------------------------------------- 1 | export { UserCard, UserCard as default } from './UserCard'; 2 | -------------------------------------------------------------------------------- /.github/profile/PROGRESS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/.github/profile/PROGRESS.png -------------------------------------------------------------------------------- /web/ui/cypress/fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Atomys", 3 | "email": "devnull@atomys.codes" 4 | } 5 | -------------------------------------------------------------------------------- /web/ui/src/components/AuthError/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthError, AuthError as default } from './AuthError'; 2 | -------------------------------------------------------------------------------- /.github/profile/logo-emoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/.github/profile/logo-emoji.png -------------------------------------------------------------------------------- /web/ui/src/components/Loader/index.ts: -------------------------------------------------------------------------------- 1 | export { Loader as default, Loader, LoaderSpinner } from './Loader'; 2 | -------------------------------------------------------------------------------- /web/ui/src/components/UserProfile/index.ts: -------------------------------------------------------------------------------- 1 | export { UserProfile, UserProfile as default } from './UserProfile'; 2 | -------------------------------------------------------------------------------- /web/ui/public/assets/images/kappa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/images/kappa.png -------------------------------------------------------------------------------- /web/ui/src/components/NewFeaturePing/index.ts: -------------------------------------------------------------------------------- 1 | export { NewFeaturePing as default, NewFeaturePing } from './NewFeaturePing'; 2 | -------------------------------------------------------------------------------- /web/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /web/ui/src/components/Notice/index.ts: -------------------------------------------------------------------------------- 1 | export { Notice } from './Notice'; 2 | export { RemoteNotices } from './RemoteNotices'; 3 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /web/ui/public/assets/images/logo_bg_slate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/images/logo_bg_slate.png -------------------------------------------------------------------------------- /web/ui/src/components/Avatar/index.ts: -------------------------------------------------------------------------------- 1 | export { Avatar, Avatar as default } from './Avatar'; 2 | export type { AvatarProps } from './types'; 3 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /web/ui/src/components/Notification/index.ts: -------------------------------------------------------------------------------- 1 | export { Notification } from './Notification'; 2 | export type { NotificationProps } from './types'; 3 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /web/ui/src/components/Tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | export { Tooltip as default, Tooltip } from './Tooltip'; 2 | export type { TooltipProps } from './types'; 3 | -------------------------------------------------------------------------------- /web/ui/.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | node_modules/ 3 | cypress/ 4 | coverage/ 5 | 6 | # Generated code 7 | src/graphql/generated.ts 8 | src/graphql/schema.json -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/42atomys/stud42/HEAD/web/ui/public/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/ui/src/components/ConditionalWrapper/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ConditionalWrapper, 3 | ConditionalWrapper as default, 4 | } from './ConditionalWrapper'; 5 | -------------------------------------------------------------------------------- /web/ui/src/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | export { useSidebar, useSidebar as default } from './hooks'; 2 | export { Menu, MenuCategory, MenuItem } from './SidebarMenu'; 3 | -------------------------------------------------------------------------------- /web/ui/src/lib/GraphqlAdapter/index.ts: -------------------------------------------------------------------------------- 1 | export { GraphQLAdapter as default } from './graphql-adapter'; 2 | 3 | export type { DuoContext, GithubContext } from './types'; 4 | -------------------------------------------------------------------------------- /web/ui/src/components/Name/index.ts: -------------------------------------------------------------------------------- 1 | export { Name, Name as default } from './Name'; 2 | export type { NameProps } from './types'; 3 | export { formatName } from './utils'; 4 | -------------------------------------------------------------------------------- /web/ui/src/components/Notice/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from '@graphql.d'; 2 | 3 | export type NoticeProps = { 4 | notice: Omit; 5 | }; 6 | -------------------------------------------------------------------------------- /web/ui/src/components/UserProfile/types.d.ts: -------------------------------------------------------------------------------- 1 | export type UserProfileProps = { 2 | userId: string; 3 | open: boolean; 4 | setOpen: Dispatch>; 5 | }; 6 | -------------------------------------------------------------------------------- /deploy/modules/service/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | kubernetes = { 4 | source = "hashicorp/kubernetes" 5 | version = ">= 2.14" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/ui/src/__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | const fileMock = { 2 | src: '/img.jpg', 3 | height: 24, 4 | width: 24, 5 | blurDataURL: 'data:image/png;base64,imagedata', 6 | }; 7 | 8 | export default fileMock; 9 | -------------------------------------------------------------------------------- /web/ui/src/components/UserPopup/index.ts: -------------------------------------------------------------------------------- 1 | export { PopupProvider, PopupConsumer } from './utils'; 2 | export { UserPopup } from './UserPopup'; 3 | 4 | export type { Actions, PayloadOf } from './types'; 5 | -------------------------------------------------------------------------------- /web/ui/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Error from 'next/error'; 2 | 3 | export default function NotFound() { 4 | // Opinionated: do not record an exception in Sentry for 404 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord Community Support 4 | url: https://discord.gg/RjheCdau42 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /deploy/stacks/apps/configs/postgres/init-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 5 | \c s42 6 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 7 | EOSQL -------------------------------------------------------------------------------- /deploy/stacks/apps/configs/webhooked/github_template.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "metadata" : { 3 | "specName": "{{ .Spec.Name }}", 4 | "event": "{{ .Request.Header | getHeader "x-github-event" }}" 5 | }, 6 | "payload": {{ .Payload }} 7 | } 8 | -------------------------------------------------------------------------------- /web/ui/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /web/ui/src/components/Portal/types.d.ts: -------------------------------------------------------------------------------- 1 | type PortalProps = PortalInstance & { 2 | singleton?: boolean; 3 | }; 4 | 5 | type PortalInstance = { 6 | // The DOM id of the portal 7 | portalDOMId: string; 8 | key?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /web/ui/src/types/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/index.ts: -------------------------------------------------------------------------------- 1 | export { ColorInput } from './ColorInput'; 2 | export { FileInput } from './FileInput'; 3 | export { SelectInput } from './SelectInput'; 4 | export { Switch } from './Switch'; 5 | export { TextInput } from './TextInput'; 6 | -------------------------------------------------------------------------------- /web/ui/src/containers/friends/index.ts: -------------------------------------------------------------------------------- 1 | export { FriendsGroupAddOrEditModal } from './FriendsGroupAddOrEditModal'; 2 | export { FriendsGroupDeleteModal } from './FriendsGroupDeleteModal'; 3 | export { FriendsGroupManageModal } from './FriendsGroupManageModal'; 4 | -------------------------------------------------------------------------------- /web/ui/src/containers/settings/index.ts: -------------------------------------------------------------------------------- 1 | export { SettingsCategory } from './SettingsCategory'; 2 | export { SettingsLayout } from './SettingsLayout'; 3 | export { SettingsTable, SettingsTableRow } from './SettingsTable'; 4 | export { ThemePreview } from './ThemePreview'; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "https://json.schemastore.org/github-workflow.json": "file:///workspace/.github/workflows/deploy.yaml" 4 | }, 5 | "tailwindCSS.experimental.classRegex": [ 6 | ["tv\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /deploy/modules/sealed-secrets/outputs.tf: -------------------------------------------------------------------------------- 1 | output "sealedSecrets" { 2 | depends_on = [ 3 | kubernetes_manifest.sealed_secret 4 | ] 5 | 6 | value = { 7 | for k, v in local.safeSealedSecrets : k => merge(v, { 8 | secretName = k 9 | }) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/pkg/searchengine/searchengine.go: -------------------------------------------------------------------------------- 1 | package searchengine 2 | 3 | // Initizialize the search engine. 4 | // It should be called at startup. (should be called after viper is initialized 5 | // due to dependency on viper) 6 | func Initizialize() { 7 | initMeilisearchDependency() 8 | } 9 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/configs/monitoring/tempo.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 3100 3 | storage: 4 | trace: 5 | backend: local 6 | local: 7 | path: /data/traces 8 | wal: 9 | path: /data/wal 10 | overrides: 11 | per_tenant_override_config: /etc/tempo/overrides.yaml 12 | -------------------------------------------------------------------------------- /web/ui/src/lib/jsonParser.ts: -------------------------------------------------------------------------------- 1 | // A wrapper for "JSON.parse()"" to support "undefined" value 2 | export const parseJSONSafely = (value: string | null): T | undefined => { 3 | try { 4 | return value === 'undefined' ? undefined : JSON.parse(value ?? ''); 5 | } catch { 6 | return undefined; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /web/ui/src/components/Button/types.d.ts: -------------------------------------------------------------------------------- 1 | export type ButtonProps = { 2 | onClick: () => void; 3 | disabled?: boolean; 4 | color?: 5 | | 'primary' 6 | | 'secondary' 7 | | 'danger' 8 | | 'success' 9 | | 'warning' 10 | | 'info' 11 | | 'black'; 12 | size?: 'sm' | 'md' | 'lg' | 'xl'; 13 | }; 14 | -------------------------------------------------------------------------------- /deploy/stacks/apps/webhooked/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | // nodepoolSelector is a selector that matches all nodes in the node pool 3 | // that the application is deployed to 4 | nodepoolSelector = { 5 | nodepool = var.namespace == "production" ? "medium" : var.namespace == "staging" ? "small" : "small-shared" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/ui/src/lib/clustersMap/index.ts: -------------------------------------------------------------------------------- 1 | export { countryEmoji } from './countryEmoji'; 2 | export { Campuses, Campuses as default } from './campuses.generated'; 3 | export { findCampusPerSafeLink } from './utils'; 4 | export type { CampusIdentifier } from './types.generated'; 5 | export type { ClusterMapEntity, ICampus, ICluster } from './types'; 6 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // serveCmd represents the serve command 8 | var serveCmd = &cobra.Command{ 9 | Use: "serve", 10 | Short: "Serve is a subcommand to serve stud42 application in production", 11 | } 12 | 13 | func init() { 14 | rootCmd.AddCommand(serveCmd) 15 | } 16 | -------------------------------------------------------------------------------- /web/ui/src/components/ConditionalWrapper/types.d.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | 3 | export type ConditionalWrapperFn = (props: { 4 | condition: boolean; 5 | trueWrapper: (children: ReactElement) => ReactElement; 6 | falseWrapper?: (children: ReactElement) => ReactElement; 7 | children: ReactElement; 8 | }) => ReactElement; 9 | -------------------------------------------------------------------------------- /internal/models/gotype/settings.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | type Settings struct { 4 | Theme Theme `json:"theme"` 5 | ClusterMapAvatarSize ClusterMapAvatarSize `json:"clusterMapAvatarSize"` 6 | } 7 | 8 | var ( 9 | DefaultSettings = Settings{Theme: ThemeAuto, ClusterMapAvatarSize: ClusterMapAvatarSizeAuto} 10 | ) 11 | -------------------------------------------------------------------------------- /web/ui/src/lib/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Julien CARON 2 | // Credits go to https://github.com/juliencrn/usehooks-ts 3 | import { useEffect, useLayoutEffect } from 'react'; 4 | 5 | export const useIsomorphicLayoutEffect = 6 | typeof window !== 'undefined' ? useLayoutEffect : useEffect; 7 | 8 | export default useIsomorphicLayoutEffect; 9 | -------------------------------------------------------------------------------- /web/ui/src/components/ClusterMap/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ClusterEmpty, 3 | ClusterPillar, 4 | ClusterRow, 5 | ClusterTableMap, 6 | ClusterWorkspace, 7 | ClusterWorkspaceWithUser, 8 | } from './ClusterTableMap'; 9 | export type { ClusterContainerProps, MapLocation } from './types'; 10 | export { extractNode, extractandRemoveNode } from './utils'; 11 | -------------------------------------------------------------------------------- /cmd/operations.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // operationsCmd represents the operations command 8 | var operationsCmd = &cobra.Command{ 9 | Use: "operations", 10 | Short: "operations is a command that will do operations on the data", 11 | } 12 | 13 | func init() { 14 | rootCmd.AddCommand(operationsCmd) 15 | } 16 | -------------------------------------------------------------------------------- /web/ui/src/components/Search/types.d.ts: -------------------------------------------------------------------------------- 1 | import { SearchUserQueryVariables, User } from '@graphql.d'; 2 | 3 | export type SearchComponent = React.ComponentType; 4 | 5 | export type SearchProps = { 6 | action: (user: User) => Promise; 7 | placeholder: string; 8 | searchVariables?: Partial; 9 | icon: string; 10 | }; 11 | -------------------------------------------------------------------------------- /deploy/stacks/apps/configs/webhooked/template.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "specName": "{{ .Spec.Name }}", 4 | "model": "{{ .Request.Header | getHeader "X-Model" }}", 5 | "event": "{{ .Request.Header | getHeader "X-Event" }}", 6 | "deliveryID": "{{ .Request.Header | getHeader "X-Delivery" | default "unknown" }}" 7 | }, 8 | "payload": {{ .Payload }} 9 | } -------------------------------------------------------------------------------- /web/ui/jest.setup.ts: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | 4 | // Used for __tests__/testing-library.js 5 | // Learn more: https://github.com/testing-library/jest-dom 6 | import '@testing-library/jest-dom/extend-expect'; 7 | import './src/lib/prototypes/string'; 8 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | #1e293b 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /web/ui/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GRAPHQL_API=http://localhost:4000/graphql 2 | CONFIG_PATH=../../config/stud42.yaml 3 | S42_SERVICE_TOKEN=private-cross-service-token 4 | 5 | FORTY_TWO_ID= 6 | FORTY_TWO_SECRET= 7 | 8 | GITHUB_ID= 9 | GITHUB_SECRET= 10 | 11 | DISCORD_ID= 12 | DISCORD_SECRET= 13 | 14 | NEXTAUTH_URL=http://localhost:3000 15 | NEXTAUTH_SECRET=very-private-next-auth-secret 16 | -------------------------------------------------------------------------------- /web/ui/src/components/AuthError/types.d.ts: -------------------------------------------------------------------------------- 1 | type ErrorType = 2 | | 'default' 3 | | 'configuration' 4 | | 'accessdenied' 5 | | 'verification' 6 | | 'oauthcreateaccount' 7 | | 'oauthaccountnotlinked' 8 | | 'oauthcallback' 9 | | 'oauthsignin' 10 | | 'callback' 11 | | 'sessionrequired'; 12 | 13 | type ErrorView = { 14 | name: string; 15 | message: string; 16 | }; 17 | -------------------------------------------------------------------------------- /pkg/duoapi/endpoints.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | var ( 4 | // Endpoints is the list of endpoints 5 | EndpointBaseAPI = "https://api.intra.42.fr" 6 | EndpointVersion = "/v2" 7 | 8 | EndpointCampus = EndpointBaseAPI + EndpointVersion + "/campus" 9 | EndpointLocations = EndpointBaseAPI + EndpointVersion + "/locations" 10 | EndpointUsers = EndpointBaseAPI + EndpointVersion + "/users" 11 | ) 12 | -------------------------------------------------------------------------------- /web/ui/src/pages/friends/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps, NextPage } from 'next'; 2 | 3 | const NullPage: NextPage = () => null; 4 | 5 | export const getServerSideProps: GetServerSideProps = async () => { 6 | return { 7 | redirect: { 8 | destination: '/friends/all', 9 | permanent: false, 10 | }, 11 | props: {}, 12 | }; 13 | }; 14 | 15 | export default NullPage; 16 | -------------------------------------------------------------------------------- /web/ui/src/components/UserCard/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Location, User } from '@graphql.d'; 2 | import { NestedPartial } from 'types/utils'; 3 | 4 | type UserCardProps = Pick & { 5 | user: User; 6 | location?: NestedPartial | null; 7 | }; 8 | 9 | type DropdownMenuProps = { 10 | user: Pick; 11 | buttonAlwaysShow?: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /web/ui/src/components/Sidebar/SidebarContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | type SidebarContextType = { 4 | open: boolean; 5 | setOpen: (c: boolean) => void; 6 | }; 7 | 8 | /** 9 | * Sidebar context. This is used to open and close the sidebar in mobile view. 10 | */ 11 | export const SidebarContext = createContext({ 12 | open: false, 13 | setOpen: () => {}, 14 | }); 15 | -------------------------------------------------------------------------------- /internal/discord/utils.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // InviteOnOurDiscord invites a user to the S42 server using the Discord API. 8 | // the discord guild id can be set in the environment variable DISCORD_GUILD_ID. 9 | func (c *Client) InviteOnOurDiscord(ctx context.Context, token, userID string) error { 10 | return c.GuildMemberAdd(token, DefaultGuildID(), userID, "", []string{}, false, false) 11 | } 12 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe the pull request** 2 | 3 | 4 | 5 | **Checklist** 6 | 7 | - [ ] I have made the modifications or added tests related to my PR 8 | - [ ] I have run the tests and linters locally and they pass 9 | - [ ] I have added/updated the documentation for my RP 10 | 11 | **Additional context** 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/index.ts: -------------------------------------------------------------------------------- 1 | export { Badge } from './Badge'; 2 | export { AkaBadgy, BetaBadgy, DeprecatedBadgy, NewBadgy } from './Badgy'; 3 | export { FlagBadge } from './FlagBadge'; 4 | export { LocationBadge } from './LocationBadge'; 5 | export { ThridPartyBadge } from './ThridPartyBadge'; 6 | export { flagData, thridPartyData } from './data'; 7 | export type { BadgeColor, BadgeProps } from './types'; 8 | export { thirdPartySorted } from './utils'; 9 | -------------------------------------------------------------------------------- /deploy/stacks/apps/webhooked/variables.tf: -------------------------------------------------------------------------------- 1 | variable "enabled" { 2 | type = bool 3 | description = "Whether the webhooked application should be deployed or not" 4 | default = true 5 | } 6 | 7 | variable "namespace" { 8 | type = string 9 | description = "Namespace of the application" 10 | } 11 | 12 | 13 | variable "appVersion" { 14 | type = string 15 | description = "Version of the application" 16 | } 17 | -------------------------------------------------------------------------------- /cmd/jobs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // jobsCmd represents the jobs command 8 | var jobsCmd = &cobra.Command{ 9 | Use: "jobs", 10 | Short: "Ajobs is a sub command for managing async jobs", 11 | Long: `jobs is a sub command for managing async jobs. This command 12 | contains subcommands for execution one time jobs, and cron jobs.`, 13 | } 14 | 15 | func init() { 16 | rootCmd.AddCommand(jobsCmd) 17 | } 18 | -------------------------------------------------------------------------------- /web/ui/src/components/Name/utils.ts: -------------------------------------------------------------------------------- 1 | import { NameFormatable } from './types'; 2 | 3 | export const formatName = ( 4 | obj: NameFormatable, 5 | opts: { displayLogin: boolean } = { 6 | displayLogin: false, 7 | }, 8 | ) => { 9 | const formattedName = [ 10 | obj.usualFirstName || obj.firstName, 11 | opts.displayLogin ? `(@${obj.duoLogin})` : null, 12 | obj.lastName, 13 | ]; 14 | 15 | return formattedName.filter(Boolean).join(' '); 16 | }; 17 | -------------------------------------------------------------------------------- /deploy/modules/service/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | defaultLabels = { 3 | "app" = var.appName 4 | "version" = var.appVersion 5 | "kubernetes.io/name" = var.name 6 | "app.kubernetes.io/version" = var.appVersion 7 | "app.kubernetes.io/part-of" = var.appName 8 | "app.kubernetes.io/managed-by" = "terraform" 9 | "app.kubernetes.io/created-by" = "github-actions" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 3.0.x | :white_check_mark: | 11 | | < 3.0 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | To report a security breach, please contact a Maintainers via the Discord s42 server. 16 | -------------------------------------------------------------------------------- /web/ui/src/components/Sidebar/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { PageContainer, PageContent, Sidebar } from './Sidebar'; 2 | import { SidebarProvider } from './SidebarProvider'; 3 | 4 | /** 5 | * useSidebar hook. This is used to give all context to use the sidebar 6 | * component correctly. 7 | */ 8 | export const useSidebar = () => { 9 | return { 10 | SidebarProvider, 11 | Sidebar, 12 | PageContent, 13 | PageContainer, 14 | }; 15 | }; 16 | 17 | export default useSidebar; 18 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base 2 | 3 | # Avoid warnings by switching to noninteractive 4 | ENV DEBIAN_FRONTEND=noninteractive \ 5 | TERM=xterm 6 | 7 | RUN apt-get update \ 8 | && apt-get -y install --no-install-recommends wget \ 9 | && wget -O /usr/local/bin/rabbitmqadmin https://raw.githubusercontent.com/rabbitmq/rabbitmq-management/v3.7.8/bin/rabbitmqadmin \ 10 | && chmod +x /usr/local/bin/rabbitmqadmin 11 | 12 | WORKDIR /workspace 13 | -------------------------------------------------------------------------------- /.devcontainer/.env: -------------------------------------------------------------------------------- 1 | #! NEVER PUSH THIS FILE WHEN EDITED 2 | 3 | FORTY_TWO_ID= 4 | FORTY_TWO_SECRET= 5 | 6 | DISCORD_ID= 7 | DISCORD_SECRET= 8 | DISCORD_TOKEN= 9 | 10 | GITHUB_ID= 11 | GITHUB_SECRET= 12 | 13 | GITLAB_ID= 14 | GITLAB_SECRET= 15 | 16 | INSTAGRAM_ID= 17 | INSTAGRAM_SECRET= 18 | 19 | LINKEDIN_ID= 20 | LINKEDIN_SECRET= 21 | 22 | REDDIT_ID= 23 | REDDIT_SECRET= 24 | 25 | SPOTIFY_ID= 26 | SPOTIFY_SECRET= 27 | 28 | TWITTER_ID= 29 | TWITTER_SECRET= 30 | 31 | TWITCH_ID= 32 | TWITCH_SECRET= 33 | -------------------------------------------------------------------------------- /web/ui/src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | 3 | type PageProps = {}; 4 | 5 | const Home: NextPage = () => <>; 6 | 7 | export const getServerSideProps: GetServerSideProps = async ({ query }) => { 8 | const { category } = query; 9 | 10 | return { 11 | redirect: { 12 | destination: `/settings/${category || 'profile'}`, 13 | permanent: false, 14 | }, 15 | props: {}, 16 | }; 17 | }; 18 | 19 | export default Home; 20 | -------------------------------------------------------------------------------- /web/ui/public/assets/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stud42", 3 | "short_name": "Stud42", 4 | "icons": [ 5 | { 6 | "src": "/assets/favicon/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/favicon/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#1e293b", 17 | "background_color": "#1e293b", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /web/ui/src/components/Tooltip/types.d.ts: -------------------------------------------------------------------------------- 1 | export type TooltipProps = { 2 | children: React.ReactNode; 3 | className?: string; 4 | tooltipClassName?: string; 5 | text: string | React.ReactNode; 6 | subText?: string | React.ReactNode; 7 | showArrow?: boolean; 8 | size?: 'xs' | 'sm' | 'md'; 9 | color?: 'red' | 'orange' | 'green' | 'info' | 'black' | 'fuchsia'; 10 | direction?: 'top' | 'bottom' | 'left' | 'right'; 11 | allowInteractions?: boolean; 12 | }; 13 | 14 | type TooltipComponent = (props: TooltipProps) => JSX.Element; 15 | -------------------------------------------------------------------------------- /deploy/stacks/apps/apps.tf: -------------------------------------------------------------------------------- 1 | module "s42" { 2 | source = "./s42" 3 | 4 | appVersion = var.appsVersion["s42"] 5 | namespace = var.namespace 6 | 7 | rootDomain = var.baseUrl 8 | hasPersistentStorage = var.hasPersistentStorage 9 | crawlerEnabled = var.crawlerEnabled 10 | webhookProcessorEnabled = var.webhooksEnabled 11 | } 12 | 13 | module "webhooked" { 14 | source = "./webhooked" 15 | enabled = var.webhooksEnabled 16 | 17 | appVersion = "0.6.4" 18 | namespace = var.namespace 19 | } 20 | -------------------------------------------------------------------------------- /web/ui/src/components/AuthError/__tests__/__snapshots__/AuthError.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshot: renders Tooltip unchanged 1`] = ` 4 |
5 |
8 |

11 | An error occured 12 |

13 |

16 | An error occured, please try again later 17 |

18 |
19 |
20 | `; 21 | -------------------------------------------------------------------------------- /web/ui/src/components/Emoji/types.d.ts: -------------------------------------------------------------------------------- 1 | import { ImageProps } from 'next/image'; 2 | 3 | // Props of EmojiWrapper 4 | interface Props extends Omit { 5 | // The emoji to display 6 | emoji: string; 7 | // Size of the svg rendered 8 | size?: number; 9 | // Container Class applied to the wrapper element. It's optional because it's 10 | // not needed by default. It's useful to add a class to the wrapper element 11 | // to style it. Exemple: Add margin arround the Emoji 12 | containerClassName?: string; 13 | } 14 | -------------------------------------------------------------------------------- /pkg/utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | // StringLimiter will limit a string to a specific limit and drop rest of the 10 | // string 11 | // 12 | // Example: 13 | // StringLimiter("hello world", 15) => "hello world" 14 | // StringLimiter("hello world", 3) => "hell" 15 | func StringLimiter(str string, limit int) string { 16 | reader := strings.NewReader(str) 17 | var buff = make([]byte, limit) 18 | 19 | _, _ = io.ReadAtLeast(reader, buff, limit) 20 | 21 | return string(bytes.Trim(buff, "\x00")) 22 | } 23 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/__tests__/data.test.ts: -------------------------------------------------------------------------------- 1 | import { countryNameToEmoji } from '../data'; 2 | 3 | describe('countryMap has good format', () => { 4 | Object.keys(countryNameToEmoji).forEach((key) => { 5 | const emoji = countryNameToEmoji[key]; 6 | 7 | it(`has emoji for ${key} that is a single character that is a valid emoji that is a flag`, () => { 8 | expect(key).toBeTruthy(); 9 | expect(emoji).toBeTruthy(); 10 | expect(typeof emoji).toBe('string'); 11 | expect(emoji).toMatch(/[\u{1F1E6}-\u{1F1FF}]/u); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /cmd/crawler.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // crawlerCmd represents the crawler command 8 | var crawlerCmd = &cobra.Command{ 9 | Use: "crawler", 10 | Short: "crawler is a sub command to manage crawlers", 11 | Long: `A crawler is a job that will be executed periodically. It will 12 | be executed on a cron schedule. It is used to update the database with 13 | new data from 42API. It is also used to update the database with new 14 | data from other sources.`, 15 | } 16 | 17 | func init() { 18 | jobsCmd.AddCommand(crawlerCmd) 19 | } 20 | -------------------------------------------------------------------------------- /web/ui/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | import Head from 'next/head'; 3 | 4 | const Home: NextPage = () => { 5 | return ( 6 |
7 | 8 | S42.App 9 | 10 | 11 |
12 | ); 13 | }; 14 | 15 | export const getServerSideProps: GetServerSideProps = async () => { 16 | return { 17 | redirect: { 18 | destination: '/clusters', 19 | permanent: true, 20 | }, 21 | }; 22 | }; 23 | 24 | export default Home; 25 | -------------------------------------------------------------------------------- /web/ui/src/containers/settings/types.d.ts: -------------------------------------------------------------------------------- 1 | const SettingsPages = 2 | 'profile' | 3 | 'apparence' | 4 | 'awesomeness' | 5 | 'accounts' | 6 | 'about' | 7 | 'help' | 8 | 'terms' | 9 | 'privacy'; 10 | 11 | export type SettingsLayoutProps = { 12 | page: (typeof SettingsPages)[number]; 13 | }; 14 | 15 | export type SettingsCategoryProps = { 16 | title?: string; 17 | description?: string; 18 | }; 19 | 20 | export type SettingsTableRowProps = { 21 | children: React.ReactNode[] | React.ReactNode; 22 | title: string; 23 | description?: string; 24 | isSponsorOnly?: boolean; 25 | }; 26 | -------------------------------------------------------------------------------- /deploy/modules/istio/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | kubernetes = { 4 | source = "hashicorp/kubernetes" 5 | version = ">= 2.14" 6 | } 7 | kubectl = { 8 | source = "gavinbunney/kubectl" 9 | version = ">= 1.7.0" 10 | } 11 | helm = { 12 | source = "hashicorp/helm" 13 | version = ">= 2.7.1" 14 | } 15 | } 16 | } 17 | 18 | 19 | provider "kubernetes" { 20 | config_path = "~/.kube/config" 21 | } 22 | 23 | provider "helm" { 24 | kubernetes { 25 | config_path = "~/.kube/config" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /deploy/modules/sealed-secrets/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | kubernetes = { 4 | source = "hashicorp/kubernetes" 5 | version = ">= 2.14" 6 | } 7 | helm = { 8 | source = "hashicorp/helm" 9 | version = ">= 2.7.1" 10 | } 11 | null = { 12 | source = "hashicorp/null" 13 | version = ">= 3.1.0" 14 | } 15 | } 16 | } 17 | 18 | 19 | provider "kubernetes" { 20 | config_path = "~/.kube/config" 21 | } 22 | 23 | provider "helm" { 24 | kubernetes { 25 | config_path = "~/.kube/config" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/ui/src/components/Sidebar/SidebarProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'react'; 2 | import { SidebarContext } from './SidebarContext'; 3 | 4 | /** 5 | * Sidebar provider. This is used to open and close the sidebar in mobile view. 6 | */ 7 | export const SidebarProvider = ({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }): JSX.Element => { 12 | const [open, setOpen] = useState(false); 13 | const store = useMemo(() => ({ open, setOpen }), [open]); 14 | 15 | return ( 16 | {children} 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /web/ui/src/components/ConditionalWrapper/ConditionalWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ConditionalWrapperFn } from './types'; 2 | 3 | /** 4 | * ConditionalWrapper will render a `trueWrapper` when the condition is true 5 | * and a `falseWrapper` when the condition is false. When no falseWrapper 6 | * is given, no wrapper is applied 7 | */ 8 | export const ConditionalWrapper: ConditionalWrapperFn = ({ 9 | condition, 10 | trueWrapper, 11 | falseWrapper = null, 12 | children, 13 | }) => { 14 | if (condition) return trueWrapper(children); 15 | else return falseWrapper ? falseWrapper(children) : children; 16 | }; 17 | -------------------------------------------------------------------------------- /web/ui/src/components/Name/types.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@graphql.d'; 2 | import { Maybe } from 'types/globals'; 3 | 4 | export type NameProps = { 5 | hasNickname?: boolean; 6 | displayLogin?: boolean; 7 | tooltipClassName?: string; 8 | tooltip?: boolean; 9 | user: Pick< 10 | User, 11 | 'firstName' | 'usualFirstName' | 'lastName' | 'duoLogin' | 'nickname' 12 | >; 13 | }; 14 | 15 | export interface NameFormatable { 16 | nickname?: Maybe; 17 | usualFirstName?: Maybe; 18 | firstName?: Maybe; 19 | duoLogin?: Maybe; 20 | lastName?: Maybe; 21 | } 22 | -------------------------------------------------------------------------------- /web/ui/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import YAML from 'yaml'; 3 | 4 | export const getConfig = (): Configuration => { 5 | if (!process.env.CONFIG_PATH) throw new Error('No CONFIG_PATH env var set.'); 6 | 7 | const fileContent = readFileSync(process.env.CONFIG_PATH, 'utf8'); 8 | return YAML.parse(fileContent) as Configuration; 9 | }; 10 | 11 | export const getServiceToken = (): string => { 12 | if (!process.env.S42_SERVICE_TOKEN) 13 | throw new Error('No S42_SERVICE_TOKEN env var set.'); 14 | 15 | return process.env.S42_SERVICE_TOKEN; 16 | }; 17 | 18 | export default getConfig; 19 | -------------------------------------------------------------------------------- /web/ui/.dockerignore: -------------------------------------------------------------------------------- 1 | # ================================================= 2 | # Frontned ignores 🕹 3 | # ================================================= 4 | 5 | # Generated code 6 | src/graphql/schema.json 7 | src/graphql/generated.ts 8 | 9 | # dependencies 10 | node_modules 11 | *.pnp 12 | *.pnp.js 13 | 14 | # testing 15 | coverage 16 | 17 | # next.js 18 | .next/ 19 | out/ 20 | 21 | # misc 22 | deploy/ 23 | docs/ 24 | config/* 25 | tools/ 26 | .github 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | *npm-debug.log* 32 | *yarn-debug.log* 33 | *yarn-error.log* 34 | 35 | # vercel 36 | *.vercel 37 | 38 | # typescript 39 | **.tsbuildinfo 40 | -------------------------------------------------------------------------------- /pkg/utils/string_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestStringLimiter(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | str string 9 | limit int 10 | want string 11 | }{ 12 | {"limit to same count", "hello world", 11, "hello world"}, 13 | {"limit to less count", "hello world", 4, "hell"}, 14 | {"limit to more count", "hello world", 15, "hello world"}, 15 | } 16 | for _, tt := range tests { 17 | t.Run(tt.name, func(t *testing.T) { 18 | if got := StringLimiter(tt.str, tt.limit); got != tt.want { 19 | t.Errorf("StringLimiter() = %v, want %v", got, tt.want) 20 | } 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/ui/src/components/ColorDisplay/ColorDisplay.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import type { DataType } from 'csstype'; 3 | import { Maybe, PropsWithClassName } from 'types/globals'; 4 | 5 | export const ColorDisplay: React.FC< 6 | PropsWithClassName<{ color: Maybe }> 7 | > = ({ color, className }) => ( 8 |
18 | ); 19 | -------------------------------------------------------------------------------- /deploy/stacks/apps/s42/configs.tf: -------------------------------------------------------------------------------- 1 | resource "kubernetes_config_map" "stud42_config" { 2 | metadata { 3 | name = "stud42-config" 4 | namespace = var.namespace 5 | labels = { 6 | "kubernetes.io/name" = "stud42-config" 7 | "app.kubernetes.io/part-of" = "stud42" 8 | "app.kubernetes.io/managed-by" = "terraform" 9 | "app.kubernetes.io/created-by" = "github-actions" 10 | } 11 | } 12 | 13 | data = { 14 | "stud42.yaml" = templatefile("${path.root}/configs/stud42/stud42.yaml.tftpl", { 15 | rootDomain = var.rootDomain 16 | namespace = var.namespace 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/ui/src/lib/useEventCallback.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Julien CARON 2 | // Credits go to https://github.com/juliencrn/usehooks-ts 3 | import { useCallback, useRef } from 'react'; 4 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 5 | 6 | export default function useEventCallback( 7 | fn: (...args: Args) => R, 8 | ) { 9 | const ref = useRef(() => { 10 | throw new Error('Cannot call an event handler while rendering.'); 11 | }); 12 | 13 | useIsomorphicLayoutEffect(() => { 14 | ref.current = fn; 15 | }, [fn]); 16 | 17 | return useCallback((...args: Args) => ref.current(...args), [ref]); 18 | } 19 | -------------------------------------------------------------------------------- /web/ui/src/components/Notice/RemoteNotices.tsx: -------------------------------------------------------------------------------- 1 | import { useMe } from '@ctx/currentUser'; 2 | import { Notice } from './Notice'; 3 | 4 | /** 5 | * Get the current user and all the notices that he/she has not read yet 6 | * and display them in the top of the page. Notices are fetched from the API. 7 | */ 8 | export const RemoteNotices: React.FC = () => { 9 | const { 10 | me: { activesNotices = [] }, 11 | } = useMe(); 12 | 13 | if (activesNotices.length === 0) { 14 | return null; 15 | } 16 | 17 | return ( 18 |
19 | {activesNotices.map((notice) => ( 20 | 21 | ))} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /web/ui/src/lib/storageKeys.ts: -------------------------------------------------------------------------------- 1 | export const formatKey = (key: string): string => { 2 | // This regular expression finds camelCase and PascalCase 3 | const formattedKey = key.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2'); 4 | 5 | // This line replaces any non-alphanumeric and non-hyphen characters with hyphens 6 | // and converts to lowercase 7 | return formattedKey.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); 8 | }; 9 | 10 | export const LocalStorageKeys = { 11 | NewFeatureReadStatus: (feature: string) => 12 | `s42.new-feature-read-status-${formatKey(feature)}`, 13 | } as const; 14 | 15 | export const SessionStorageKeys = { 16 | GithubStars: 's42.github.stars', 17 | }; 18 | -------------------------------------------------------------------------------- /web/ui/src/lib/clustersMap/types.generated.d.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT THIS FILE MANUALLY - IT IS GENERATED FROM THE CONTENT OF THE CAMPUS FOLDER 2 | // RUN `yarn generate:campus` TO REGENERATE IT 3 | 4 | /** 5 | * List of all campus names present in the interface as their identifier. 6 | * Identifier must be in camelCase without spaces or special characters. It 7 | * must be unique in the list. 8 | */ 9 | export type CampusIdentifier = 10 | | 'angouleme' 11 | | 'helsinki' 12 | | 'lausanne' 13 | | 'leHavre' 14 | | 'madrid' 15 | | 'malaga' 16 | | 'mulhouse' 17 | | 'paris' 18 | | 'saoPaulo' 19 | | 'seoul' 20 | | 'tokyo' 21 | | 'urduliz' 22 | | 'vienna' 23 | | 'wolfsburg'; 24 | -------------------------------------------------------------------------------- /deploy/stacks/apps/s42/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | kubernetes = { 4 | source = "hashicorp/kubernetes" 5 | version = ">= 2.14" 6 | } 7 | helm = { 8 | source = "hashicorp/helm" 9 | version = ">= 2.7.1" 10 | } 11 | } 12 | } 13 | 14 | provider "kubernetes" { 15 | config_path = "~/.kube/config" 16 | 17 | ignore_labels = [ 18 | "security.istio.io/tlsMode", 19 | "service.istio.io/canonical-name", 20 | "service.istio.io/canonical-revision" 21 | ] 22 | 23 | ignore_annotations = [ 24 | "sidecar.istio.io/status", 25 | ] 26 | } 27 | 28 | provider "helm" { 29 | kubernetes { 30 | config_path = "~/.kube/config" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/webhooks/marshaler.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | // unmarshalWebhook is an helper function to unmarshal the webhook payload with 10 | // the desired type of metadata and payload. Thanks to the Go generics, this 11 | // function can be used with any type of metadata and payload. 12 | func unmarshalWebhook[Metadata any, Payload any](data []byte) (*webhook[Metadata, Payload], error) { 13 | webhookPayload := &webhook[Metadata, Payload]{} 14 | 15 | if err := json.Unmarshal(data, &webhookPayload); err != nil { 16 | log.Error().Err(err).Msg("Failed to unmarshal webhook payload") 17 | return nil, err 18 | } 19 | 20 | return webhookPayload, nil 21 | } 22 | -------------------------------------------------------------------------------- /deploy/stacks/apps/webhooked/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | kubernetes = { 4 | source = "hashicorp/kubernetes" 5 | version = ">= 2.14" 6 | } 7 | helm = { 8 | source = "hashicorp/helm" 9 | version = ">= 2.7.1" 10 | } 11 | } 12 | } 13 | 14 | provider "kubernetes" { 15 | config_path = "~/.kube/config" 16 | 17 | ignore_labels = [ 18 | "security.istio.io/tlsMode", 19 | "service.istio.io/canonical-name", 20 | "service.istio.io/canonical-revision" 21 | ] 22 | 23 | ignore_annotations = [ 24 | "sidecar.istio.io/status", 25 | ] 26 | } 27 | 28 | provider "helm" { 29 | kubernetes { 30 | config_path = "~/.kube/config" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/graphs/directives.graphqls: -------------------------------------------------------------------------------- 1 | """ 2 | @authzByPolicy directive is used to secure a field behind a security Policy. 3 | The directive takes a policy name as an argument. 4 | """ 5 | directive @authzByPolicy(policy: SecurityPolicy) on FIELD_DEFINITION 6 | """ 7 | @authenticated directive is used to define a field that requires the user to be 8 | authenticated. 9 | """ 10 | directive @authenticated on FIELD_DEFINITION 11 | """ 12 | @extraStructTag directive is used to add extra struct tags on go type to a 13 | field. The directive takes a string as an argument which is the struct tag to 14 | add. The directive is skiped in the runtime due to the fact that it is only 15 | used in the code generation. 16 | """ 17 | directive @extraStructTag on FIELD_DEFINITION 18 | -------------------------------------------------------------------------------- /web/ui/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /pkg/cache/option.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "time" 4 | 5 | type option func(*SetOption) 6 | 7 | type SetOption struct { 8 | Expiration time.Duration 9 | } 10 | 11 | // WithExpiration sets the expiration time for the cache key. 12 | // If the expiration is set to 0, the key will never expire. 13 | func WithExpiration(expiration time.Duration) option { 14 | return func(o *SetOption) { 15 | o.Expiration = expiration 16 | } 17 | } 18 | 19 | // ApplyOptions applies the given options to the SetOption struct. 20 | // If no options are given, the default options are used. 21 | func ApplyOptions(opts ...option) SetOption { 22 | o := SetOption{ 23 | Expiration: 0, 24 | } 25 | for _, opt := range opts { 26 | opt(&o) 27 | } 28 | return o 29 | } 30 | -------------------------------------------------------------------------------- /web/ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx}', 6 | './src/containers/**/*.{js,ts,jsx,tsx}', 7 | './src/components/**/*.{js,ts,jsx,tsx}', 8 | './src/lib/**/*.{js,ts,jsx,tsx}', 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | display: ['Exo\\ 2', 'sans-serif'], 14 | }, 15 | screens: { 16 | '3xl': '1792px', 17 | '4xl': '2048px', 18 | }, 19 | keyframes: { 20 | progress: { 21 | '0%': { width: '0%' }, 22 | '100%': { width: '100%' }, 23 | }, 24 | }, 25 | animation: { 26 | progress: 'progress 1s linear', 27 | }, 28 | }, 29 | }, 30 | plugins: [], 31 | }; 32 | -------------------------------------------------------------------------------- /web/ui/src/pages/feed/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSidebar } from '@components/Sidebar'; 2 | import Soon from '@components/Soon'; 3 | import Head from 'next/head'; 4 | 5 | type PageProps = {}; 6 | 7 | const IndexPage: PageProps = () => { 8 | const { SidebarProvider, Sidebar, PageContainer, PageContent } = useSidebar(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | Feed - S42 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export const getStaticProps = () => ({ 26 | props: {}, 27 | }); 28 | 29 | export default IndexPage; 30 | -------------------------------------------------------------------------------- /githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # git config core.hooksPath githooks 3 | 4 | RED="\033[1;31m" 5 | GREEN="\033[1;32m" 6 | NC="\033[0m" 7 | 8 | if ! npm list --location=global '@commitlint/cli' &> /dev/null 9 | then 10 | echo "commitlint could not be found. Installing from https://github.com/conventional-changelog/commitlint" 11 | npm install --location=global @commitlint/cli 12 | fi 13 | 14 | if ! npm list --location=global '@commitlint/config-conventional' &> /dev/null 15 | then 16 | echo "commitlint/config-conventional could not be found. Installing from https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional" 17 | npm install --location=global @commitlint/config-conventional 18 | fi 19 | 20 | npx commitlint -g $(git config core.hooksPath)/commitlint.config.js -V --edit "$1" 21 | -------------------------------------------------------------------------------- /web/ui/src/pages/statistics/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSidebar } from '@components/Sidebar'; 2 | import Soon from '@components/Soon'; 3 | import Head from 'next/head'; 4 | 5 | type PageProps = {}; 6 | 7 | const IndexPage: PageProps = () => { 8 | const { SidebarProvider, Sidebar, PageContainer, PageContent } = useSidebar(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | Statistics - S42 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export const getStaticProps = () => ({ 26 | props: {}, 27 | }); 28 | 29 | export default IndexPage; 30 | -------------------------------------------------------------------------------- /web/ui/src/lib/clustersMap/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ICampus } from './types'; 2 | import { Campuses } from './campuses.generated'; 3 | import '@lib/prototypes/string'; 4 | 5 | /** 6 | * findCampusPerSafeLink will find a campus object from its link representation. 7 | * For example, if you want to find the campus object for the campus "Paris", 8 | * you can use the link "paris" to find it. 9 | * @param slug - the link of the campus in the safe url format (e.g. "paris") 10 | * @returns the campus object or undefined if not found 11 | */ 12 | export const findCampusPerSafeLink = ( 13 | slug: string | undefined, 14 | ): ICampus | undefined => { 15 | if (!slug) return undefined; 16 | 17 | return Object.values(Campuses).find( 18 | (iCampusObject) => 19 | iCampusObject.identifier().toSafeLink() === slug.toSafeLink(), 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /web/ui/src/components/Avatar/types.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@graphql.d'; 2 | 3 | type AvatarSize = 4 | | 'xs' 5 | | 'sm' 6 | | 'md' 7 | | 'xl' 8 | | '2xl' 9 | | '3xl' 10 | | '4xl' 11 | // auto-based-on-width will only be used on cluster map currently and will be 12 | // removed once we have a better solution for the cluster map avatar sizing 13 | | 'auto-based-on-witdth' 14 | // auto-based-on-steps generate a size based on the screen width with 15 | // tailwind breakpoints 16 | | 'auto-based-on-steps'; 17 | 18 | export type AvatarProps = { 19 | duoAvatarURL?: string | null; 20 | size?: AvatarSize; 21 | rounded?: boolean; 22 | flags?: User['flags']; 23 | } & ( 24 | | { 25 | profileLink?: false; 26 | userId?: never; 27 | } 28 | | { 29 | profileLink?: true; 30 | userId: string; 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /web/ui/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /web/ui/src/containers/settings/SettingsCategory.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import { PropsWithClassName } from 'types/globals'; 4 | import { SettingsCategoryProps } from './types'; 5 | 6 | export const SettingsCategory: React.FC< 7 | React.PropsWithChildren> 8 | > = ({ children, title, description, className }) => ( 9 |
10 | {title &&

{title}

} 11 | {description && ( 12 |

{description}

13 | )} 14 |
15 | {children} 16 |
17 |
18 | ); 19 | 20 | export default SettingsCategory; 21 | -------------------------------------------------------------------------------- /internal/auth/struct.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "time" 4 | 5 | // TokenRequest is the request sent to the token endpoint 6 | type TokenRequest struct { 7 | // The ID of the user to generate a token for 8 | UserId string `json:"user_id"` 9 | // The validity of the token to generate in seconds 10 | Validity int64 `json:"validity"` 11 | // The payload to insert into the token 12 | Payload map[string]interface{} `json:"payload"` 13 | } 14 | 15 | // TokenResponse is the response returned by the token endpoint 16 | type TokenResponse struct { 17 | // The generated token 18 | Token string `json:"token"` 19 | // The ID of the token 20 | TokenId string `json:"token_id"` 21 | // The date the token was generated 22 | IssuedAt time.Time `json:"issued_at"` 23 | // The date when the token will expire 24 | ExpiresAt time.Time `json:"expires_at"` 25 | } 26 | -------------------------------------------------------------------------------- /deploy/stacks/pre-cluster/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "s42-terraform-state" 4 | key = "pre-cluster.tfstate" 5 | endpoint = "https://s3.gra.io.cloud.ovh.net/" 6 | region = "gra" 7 | skip_region_validation = true 8 | skip_credentials_validation = true 9 | } 10 | required_providers { 11 | kubernetes = { 12 | source = "hashicorp/kubernetes" 13 | version = ">= 2.20" 14 | } 15 | helm = { 16 | source = "hashicorp/helm" 17 | version = ">= 2.9.0" 18 | } 19 | } 20 | required_version = ">= v1.3.3" 21 | } 22 | 23 | provider "kubernetes" { 24 | config_path = "~/.kube/config" 25 | } 26 | 27 | provider "helm" { 28 | kubernetes { 29 | config_path = "~/.kube/config" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/ui/src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { nextAuthOptions } from '@lib/auth'; 2 | import { getActiveTransaction } from '@sentry/browser'; 3 | import type { NextApiRequest, NextApiResponse } from 'next'; 4 | import NextAuth, { AuthAction } from 'next-auth'; 5 | 6 | const auth = async (req: NextApiRequest, res: NextApiResponse) => { 7 | try { 8 | // calculate timing to log it into span to analyse it 9 | const start = Date.now(); 10 | const result = await NextAuth(req, res, nextAuthOptions); 11 | const end = Date.now(); 12 | const duration = end - start; 13 | 14 | // log the result 15 | const transaction = getActiveTransaction(); 16 | transaction?.setData('nextauth-duration', duration); 17 | transaction?.setData('nextauth-action', req.query.action as AuthAction); 18 | return result; 19 | } catch (e) {} 20 | }; 21 | 22 | export default auth; 23 | -------------------------------------------------------------------------------- /deploy/stacks/apps/s42/variables.tf: -------------------------------------------------------------------------------- 1 | variable "namespace" { 2 | type = string 3 | description = "Namespace of the application" 4 | } 5 | 6 | variable "appVersion" { 7 | type = string 8 | description = "Version of the application" 9 | } 10 | 11 | variable "rootDomain" { 12 | type = string 13 | description = "Root domain of the application" 14 | } 15 | 16 | variable "webhookProcessorEnabled" { 17 | type = bool 18 | description = "Enable the webhooks processor for the application" 19 | default = false 20 | } 21 | 22 | variable "hasPersistentStorage" { 23 | type = bool 24 | description = "Enable persistent storage for the application" 25 | default = false 26 | } 27 | 28 | variable "crawlerEnabled" { 29 | type = bool 30 | description = "Enable the crawler for the application" 31 | default = false 32 | } 33 | -------------------------------------------------------------------------------- /pkg/duoapi/campus.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | // CampusAll returns the list of all campuses in the 42 ecosystem 10 | func CampusAll(ctx context.Context) ([]*Campus, error) { 11 | var campus = make([]*Campus, 0) 12 | err := requestCollection(ctx, EndpointCampus, map[string]string{"per_page": "100"}, &campus) 13 | if err != nil { 14 | log.Error().Err(err).Msg("Failed to get response") 15 | return nil, err 16 | } 17 | return campus, nil 18 | } 19 | 20 | // CampusGet returns the campus with the given ID or nil if not found 21 | func CampusGet(ctx context.Context, campusID string) (*Campus, error) { 22 | var campus = &Campus{} 23 | err := request(ctx, EndpointCampus+"/"+campusID, nil, &campus) 24 | if err != nil { 25 | log.Error().Err(err).Msg("Failed to get response") 26 | return nil, err 27 | } 28 | return campus, nil 29 | } 30 | -------------------------------------------------------------------------------- /web/ui/src/lib/clustersMap/countryEmoji.ts: -------------------------------------------------------------------------------- 1 | export const countryEmoji: { [key: string]: string } = { 2 | Turkey: '🇹🇷', 3 | Switzerland: '🇨🇭', 4 | Italy: '🇮🇹', 5 | Luxembourg: '🇱🇺', 6 | 'Czech Republic': '🇨🇿', 7 | Armenia: '🇦🇲', 8 | 'Russian Federation': '🇷🇺', 9 | 'United Kingdom': '🇬🇧', 10 | Netherlands: '🇳🇱', 11 | Romania: '🇷🇴', 12 | Brazil: '🇧🇷', 13 | Jordan: '🇯🇴', 14 | Austria: '🇦🇹', 15 | Australia: '🇦🇺', 16 | Germany: '🇩🇪', 17 | Canada: '🇨🇦', 18 | Finland: '🇫🇮', 19 | Portugal: '🇵🇹', 20 | Ukraine: '🇺🇦', 21 | 'Moldova, Republic of': '🇲🇩', 22 | Spain: '🇪🇸', 23 | 'United Arab Emirates': '🇦🇪', 24 | Belgium: '🇧🇪', 25 | Morocco: '🇲🇦', 26 | France: '🇫🇷', 27 | 'Korea, Republic of': '🇰🇷', 28 | 'South Africa': '🇿🇦', 29 | Malaysia: '🇲🇾', 30 | Japan: '🇯🇵', 31 | Thailand: '🇹🇭', 32 | 'United States': '🇺🇸', 33 | }; 34 | -------------------------------------------------------------------------------- /deploy/modules/cert-manager/certificates.tf: -------------------------------------------------------------------------------- 1 | resource "kubectl_manifest" "certificates" { 2 | for_each = { for key, value in var.certificates : key => value } 3 | depends_on = [ 4 | helm_release.cert_manager 5 | ] 6 | 7 | yaml_body = yamlencode({ 8 | apiVersion = "cert-manager.io/v1" 9 | kind = "Certificate" 10 | metadata = { 11 | name = each.key 12 | namespace = coalesce(each.value.namespace, "istio-system") 13 | } 14 | 15 | spec = { 16 | dnsNames = each.value.dns_names 17 | duration = coalesce(each.value.duration, "2160h") # 90d 18 | renewBefore = coalesce(each.value.renew_before, "360h") # 15d 19 | issuerRef = { 20 | kind = coalesce(each.value.issuer_kind, "ClusterIssuer") 21 | name = each.value.issuer_name 22 | } 23 | secretName = coalesce(each.value.secret_name, "${each.key}-tls") 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /web/ui/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /internal/models/uuid.go: -------------------------------------------------------------------------------- 1 | package modelsutils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | 7 | "github.com/99designs/gqlgen/graphql" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // Function for converting a time-object to an RFC3339-String with GraphQL. 12 | // Returns the corresponding marshaller to perform this task. 13 | func MarshalUUID(uuid uuid.UUID) graphql.Marshaler { 14 | return graphql.WriterFunc(func(w io.Writer) { 15 | _, _ = w.Write([]byte("\"" + uuid.String() + "\"")) 16 | }) 17 | } 18 | 19 | // Function for converting a RFC3339 Time-String into an time-object. Used by GraphQL. 20 | // Returns a Time-Object representing the Time-String. 21 | func UnmarshalUUID(v interface{}) (uuid.UUID, error) { 22 | if uuidString, ok := v.(string); ok { 23 | parsedUUID, err := uuid.Parse(uuidString) 24 | if err != nil { 25 | return uuid.UUID{}, err 26 | } 27 | return parsedUUID, nil 28 | } 29 | return uuid.UUID{}, errors.New("uuid should be a string") 30 | } 31 | -------------------------------------------------------------------------------- /pkg/utils/random_color.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | // RGBColor Type 10 | type RGBColor struct { 11 | Red int 12 | Green int 13 | Blue int 14 | } 15 | 16 | // GetRandomRBGColor returns a random RGBColor struct pointer 17 | // with random values for Red, Green and Blue 18 | func GetRandomRBGColor() *RGBColor { 19 | return &RGBColor{randOnUnix().Intn(255), randOnUnix().Intn(255), randOnUnix().Intn(255)} 20 | } 21 | 22 | // GetRandomHexColor returns a random hex color string 23 | // with random values for Red, Green and Blue in hex format 24 | func GetRandomHexColor() string { 25 | bytes := make([]byte, 3) 26 | 27 | _, err := randOnUnix().Read(bytes) 28 | if err != nil { 29 | panic(err) 30 | } 31 | return fmt.Sprintf("#%x", bytes) 32 | } 33 | 34 | // randOnUnix returns a new Rand that uses a UnixNano as a seed 35 | func randOnUnix() *rand.Rand { 36 | return rand.New(rand.NewSource(time.Now().UnixNano())) 37 | } 38 | -------------------------------------------------------------------------------- /internal/models/gotype/theme.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type Theme string 10 | 11 | const ( 12 | ThemeAuto Theme = "AUTO" 13 | ThemeDark Theme = "DARK" 14 | ThemeLight Theme = "LIGHT" 15 | ) 16 | 17 | var AllTheme = []Theme{ 18 | ThemeAuto, 19 | ThemeDark, 20 | ThemeLight, 21 | } 22 | 23 | func (e Theme) IsValid() bool { 24 | switch e { 25 | case ThemeAuto, ThemeDark, ThemeLight: 26 | return true 27 | } 28 | return false 29 | } 30 | 31 | func (e Theme) String() string { 32 | return string(e) 33 | } 34 | 35 | func (e *Theme) UnmarshalGQL(v interface{}) error { 36 | str, ok := v.(string) 37 | if !ok { 38 | return fmt.Errorf("enums must be strings") 39 | } 40 | 41 | *e = Theme(str) 42 | if !e.IsValid() { 43 | return fmt.Errorf("%s is not a valid Theme", str) 44 | } 45 | return nil 46 | } 47 | 48 | func (e Theme) MarshalGQL(w io.Writer) { 49 | fmt.Fprint(w, strconv.Quote(e.String())) 50 | } 51 | -------------------------------------------------------------------------------- /web/ui/public/assets/images/logo-42.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Ce SVG est mieux fait que le svg de 42.fr . Utiliser un générateur dans une ecole de dev, vraiment, vous vous foutez de ma tronche. Big up a toi qui lis ce message 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: '3' 4 | 5 | vars: 6 | GREETING: Hello, World! 7 | 8 | tasks: 9 | generate: 10 | desc: Generate backend code based on api/*.graphql files and schemas 11 | aliases: [g, gen] 12 | cmds: 13 | - go generate generate.go 14 | build: 15 | desc: Build the application cli for production use 16 | env: 17 | CGO_ENABLED: '0' 18 | GOARCH: amd64 19 | GOOS: linux 20 | cmds: 21 | - go build -o stud42cli 22 | clean: 23 | desc: Clean up generated code 24 | cmds: 25 | - rm -rf internal/api/generated 26 | - rm -rf internal/models/generated 27 | tests: 28 | desc: Run backend tests 29 | aliases: [t, test] 30 | cmds: 31 | - go test ./... 32 | certs: 33 | desc: Generate certificates for the auth service to use 34 | cmds: 35 | - mkdir -p certs 36 | - openssl genrsa -out certs/private.key 8192 37 | - openssl rsa -in certs/private.key -out certs/public.pem -pubout -outform PEM 38 | 39 | -------------------------------------------------------------------------------- /tools/seeds/seed_user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/42atomys/stud42/internal/models/generated" 7 | ) 8 | 9 | func seedUsers() error { 10 | var users = []*generated.UserCreate{ 11 | client.User.Create().SetEmail("15014@local.dev").SetDuoID(15014).SetDuoLogin("gdalmar").SetFirstName("Gregory").SetLastName("Dalmar"), 12 | client.User.Create().SetEmail("12297@local.dev").SetDuoID(12297).SetDuoLogin("rgaiffe").SetFirstName("Remi").SetLastName("Gaiffe"), 13 | client.User.Create().SetEmail("19265@local.dev").SetDuoID(19265).SetDuoLogin("jgengo").SetFirstName("Jordane").SetLastName("Gengo"), 14 | client.User.Create().SetEmail("24007@local.dev").SetDuoID(24007).SetDuoLogin("titus").SetFirstName("Jordane").SetLastName("Gengo").SetIsStaff(true), 15 | client.User.Create().SetEmail("-1@local.dev").SetDuoID(424242001).SetDuoLogin("aperez").SetFirstName("Alice").SetLastName("Perez"), 16 | } 17 | 18 | return client.User.CreateBulk(users...).OnConflict().DoNothing().Exec(context.Background()) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/webhooks.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/getsentry/sentry-go" 7 | "github.com/rs/zerolog/log" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/42atomys/stud42/internal/pkg/searchengine" 11 | "github.com/42atomys/stud42/internal/webhooks" 12 | ) 13 | 14 | // webhooksCmd represents the webhooks command 15 | var webhooksCmd = &cobra.Command{ 16 | Use: "webhooks", 17 | Short: "webhooks process incoming webhooks from AMQP queue \"webhooks\"", 18 | PreRun: func(cmd *cobra.Command, args []string) { 19 | searchengine.Initizialize() 20 | }, 21 | 22 | Run: func(cmd *cobra.Command, args []string) { 23 | var amqpURL = os.Getenv("AMQP_URL") 24 | if amqpURL == "" { 25 | log.Fatal().Msg("AMQP_URL not set") 26 | } 27 | 28 | if err := webhooks.New().Serve(amqpURL, "webhooks.processing"); err != nil { 29 | sentry.CaptureException(err) 30 | log.Fatal().Err(err).Msg("failed to start rabbitmq consumer") 31 | } 32 | }, 33 | } 34 | 35 | func init() { 36 | jobsCmd.AddCommand(webhooksCmd) 37 | } 38 | -------------------------------------------------------------------------------- /web/ui/src/components/Sponsors/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from '@components/Tooltip'; 2 | import classNames from 'classnames'; 3 | import Link from 'next/link'; 4 | import { PropsWithClassName } from 'types/globals'; 5 | 6 | export const SponsorIcon: React.FC = ({ className }) => ( 7 | 13 | ); 14 | 15 | export const SponsorHint: React.FC = ({ className }) => ( 16 | 24 | 30 | 31 | 32 | 33 | ); 34 | -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | _ "github.com/lib/pq" 5 | "github.com/rs/zerolog/log" 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | 9 | "github.com/42atomys/stud42/internal/auth" 10 | _ "github.com/42atomys/stud42/internal/models/generated/runtime" 11 | ) 12 | 13 | var ( 14 | authHttpPortFlag *string 15 | ) 16 | 17 | // authCmd represents the auth command 18 | var authCmd = &cobra.Command{ 19 | Use: "auth", 20 | Short: "Serve the Authorization Service", 21 | 22 | Run: func(cmd *cobra.Command, args []string) { 23 | if err := auth.SetCertificates(viper.GetString("auth.jwk.certPrivateKeyFile"), viper.GetString("auth.jwk.certPublicKeyFile")); err != nil { 24 | log.Fatal().Err(err).Msg("failed to set auth keys") 25 | } 26 | 27 | log.Fatal().Err(auth.ServeHTTP(authHttpPortFlag)).Msg("failed to serve") 28 | }, 29 | } 30 | 31 | func init() { 32 | serveCmd.AddCommand(authCmd) 33 | 34 | authHttpPortFlag = authCmd.Flags().String("port", "5000", "port used to serve the http server of the jwt-provider") 35 | } 36 | -------------------------------------------------------------------------------- /internal/models/schema/follow.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type Follow struct { 12 | ent.Schema 13 | } 14 | 15 | func (Follow) Fields() []ent.Field { 16 | return []ent.Field{ 17 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 18 | Default: "uuid_generate_v4()", 19 | }), 20 | field.UUID("user_id", uuid.UUID{}), 21 | field.UUID("follow_id", uuid.UUID{}), 22 | } 23 | } 24 | 25 | func (Follow) Edges() []ent.Edge { 26 | return []ent.Edge{ 27 | edge.To("user", User.Type).Field("user_id").Unique().Required(), 28 | edge.To("follow", User.Type).Field("follow_id").Unique().Required(), 29 | 30 | edge.From("follow_groups", FollowsGroup.Type).Ref("follows"), 31 | } 32 | } 33 | 34 | func (Follow) Indexes() []ent.Index { 35 | return []ent.Index{} 36 | } 37 | 38 | func (Follow) Hooks() []ent.Hook { 39 | return []ent.Hook{} 40 | } 41 | -------------------------------------------------------------------------------- /web/ui/src/types/next.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GetServerSidePropsContext, 3 | NextComponentType, 4 | NextPageContext, 5 | } from 'next'; 6 | import type { Session } from 'next-auth'; 7 | import type { Router } from 'next/router'; 8 | import { NextRequest } from 'next/server'; 9 | 10 | export type ServerSideRequest = NextRequest | GetServerSidePropsContext['req']; 11 | 12 | declare module 'next' { 13 | type NextPage

= React.ComponentType

& { 14 | getInitialProps?(context: NextPageContext): IP | Promise; 15 | getLayout?: (page: ReactElement) => ReactNode; 16 | }; 17 | } 18 | declare module 'next/app' { 19 | type AppProps

> = { 20 | Component: NextComponentType & { 21 | getLayout?: (page: ReactElement) => ReactNode; 22 | }; 23 | router: Router; 24 | __N_SSG?: boolean; 25 | __N_SSP?: boolean; 26 | pageProps: P & { 27 | /** Initial session passed in from `getServerSideProps` or `getInitialProps` */ 28 | session?: Session; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /deploy/stacks/apps/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "s42-terraform-state" 4 | key = "apps.tfstate" 5 | endpoint = "https://s3.gra.io.cloud.ovh.net/" 6 | region = "gra" 7 | skip_region_validation = true 8 | skip_credentials_validation = true 9 | } 10 | 11 | required_providers { 12 | kubernetes = { 13 | source = "hashicorp/kubernetes" 14 | version = ">= 2.20" 15 | } 16 | helm = { 17 | source = "hashicorp/helm" 18 | version = ">= 2.9.0" 19 | } 20 | } 21 | } 22 | 23 | provider "kubernetes" { 24 | config_path = "~/.kube/config" 25 | 26 | ignore_labels = [ 27 | "security.istio.io/tlsMode", 28 | "service.istio.io/canonical-name", 29 | "service.istio.io/canonical-revision" 30 | ] 31 | 32 | ignore_annotations = [ 33 | "sidecar.istio.io/status", 34 | ] 35 | } 36 | provider "helm" { 37 | kubernetes { 38 | config_path = "~/.kube/config" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "s42-terraform-state" 4 | key = "cluster.tfstate" 5 | endpoint = "https://s3.gra.io.cloud.ovh.net/" 6 | region = "gra" 7 | skip_region_validation = true 8 | skip_credentials_validation = true 9 | } 10 | required_providers { 11 | kubernetes = { 12 | source = "hashicorp/kubernetes" 13 | version = ">= 2.20" 14 | } 15 | kubectl = { 16 | source = "gavinbunney/kubectl" 17 | version = ">= 1.11" 18 | } 19 | helm = { 20 | source = "hashicorp/helm" 21 | version = ">= 2.9.0" 22 | } 23 | random = { 24 | source = "hashicorp/random" 25 | version = "3.4.3" 26 | } 27 | } 28 | required_version = ">= v1.3.3" 29 | } 30 | 31 | provider "kubernetes" { 32 | config_path = "~/.kube/config" 33 | } 34 | 35 | provider "helm" { 36 | kubernetes { 37 | config_path = "~/.kube/config" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /web/ui/src/components/Tooltip/__tests__/Tooltip.test.tsx: -------------------------------------------------------------------------------- 1 | import TooltipDefault, { Tooltip } from '@components/Tooltip'; 2 | import { fireEvent, render, screen, waitFor } from '@testing-library/react'; 3 | 4 | it('snapshot: renders Tooltip unchanged', () => { 5 | const { container } = render( 6 | Test, 7 | ); 8 | expect(container).toMatchSnapshot(); 9 | }); 10 | 11 | it('state: hover changes', async () => { 12 | render(Test); 13 | 14 | fireEvent.mouseEnter(screen.getByTestId('hover-target')); 15 | await waitFor(() => screen.getByTestId('tooltip')); 16 | 17 | expect(screen.getByTestId('tooltip')).toHaveClass('visible z-10'); 18 | expect(screen.getByTestId('tooltip')).not.toHaveClass('invisible'); 19 | 20 | fireEvent.mouseLeave(screen.getByTestId('hover-target')); 21 | await waitFor(() => screen.getByTestId('tooltip')); 22 | 23 | expect(screen.getByTestId('tooltip')).toHaveClass('invisible'); 24 | expect(screen.getByTestId('tooltip')).not.toHaveClass('visible z-10'); 25 | }); 26 | -------------------------------------------------------------------------------- /web/ui/src/components/Tooltip/__tests__/__snapshots__/Tooltip.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshot: renders Tooltip unchanged 1`] = ` 4 |

5 |
9 | 24 |
25 | `; 26 | -------------------------------------------------------------------------------- /tools/seeds/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | _ "github.com/lib/pq" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | 11 | modelgen "github.com/42atomys/stud42/internal/models/generated" 12 | ) 13 | 14 | var client *modelgen.Client 15 | 16 | func init() { 17 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 18 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 19 | if os.Getenv("DEBUG") == "true" { 20 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 21 | } else { 22 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 23 | } 24 | 25 | var err error 26 | client, err = modelgen.Open( 27 | "postgres", 28 | os.Getenv("DATABASE_URL"), 29 | modelgen.Debug(), 30 | ) 31 | if err != nil { 32 | log.Fatal().Err(err).Msg("failed to connect to database") 33 | } 34 | 35 | if err := client.Schema.Create(context.Background()); err != nil { 36 | log.Fatal().Err(err).Msg("running schema migration") 37 | } 38 | } 39 | 40 | func main() { 41 | if err := seedUsers(); err != nil { 42 | log.Fatal().Err(err).Msg("failed to seed users") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/models/templates/marshal_binary.go.tmpl: -------------------------------------------------------------------------------- 1 | {{/* gotype: entgo.io/ent/entc/gen.Graph */}} 2 | 3 | {{ define "import/additional/marshal_binary" }} 4 | "bytes" 5 | "encoding/gob" 6 | {{ end }} 7 | 8 | {{ define "model/additional/marshal_binary" }} 9 | 10 | func ({{ $.Receiver }} *{{ $.Name }}) MarshalBinary() ([]byte, error) { 11 | return json.Marshal({{ $.Receiver }}) 12 | } 13 | 14 | func ({{ $.Receiver }} *{{ $.Name }}) UnmarshalBinary(data []byte) error { 15 | return json.Unmarshal(data, {{ $.Receiver }}) 16 | } 17 | 18 | {{ end }} 19 | 20 | {{ define "gql_pagination_marshal" }} 21 | {{ template "header" $ }} 22 | 23 | {{ range $n := $.Nodes }} 24 | {{ template "gql_pagination/helper/marshal_binary" $n }} 25 | {{ end }} 26 | {{ end }} 27 | 28 | {{ define "gql_pagination/helper/marshal_binary" }} 29 | 30 | func ({{ $.Receiver }} *{{ $.Name }}Connection) MarshalBinary() ([]byte, error) { 31 | return json.Marshal({{ $.Receiver }}) 32 | } 33 | 34 | func ({{ $.Receiver }} *{{ $.Name }}Connection) UnmarshalBinary(data []byte) error { 35 | return json.Unmarshal(data, {{ $.Receiver }}) 36 | } 37 | 38 | {{ end }} -------------------------------------------------------------------------------- /web/ui/graphqlcodegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "../../api/graphs/*.graphqls" 3 | documents: src/graphql/**/*.gql 4 | generates: 5 | src/graphql/generated.ts: 6 | hooks: 7 | afterOneFileWrite: 8 | - prettier --write 9 | - eslint --fix 10 | plugins: 11 | - "typescript" 12 | - "typescript-operations" 13 | - "typescript-react-apollo" 14 | config: 15 | # typescript-operations 16 | arrayInputCoercion: true 17 | # typescript-react-apollo 18 | addDocBlocks: true 19 | dedupeOperationSuffix: true 20 | defaultScalarType: any 21 | reactApolloVersion: 3 22 | strictScalars: true 23 | useTypeImports: true 24 | withHooks: true 25 | withMutationFn: true 26 | src/graphql/schema.json: 27 | hooks: 28 | afterOneFileWrite: 29 | - prettier --write 30 | plugins: 31 | - "introspection" 32 | config: 33 | namingConvention: 34 | typeNames: change-case-all#pascalCase 35 | enumValues: change-case-all#upperCase 36 | scalars: 37 | Any: any 38 | Cursor: any 39 | Time: Date 40 | UUID: string 41 | -------------------------------------------------------------------------------- /internal/api/resolver.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/99designs/gqlgen/graphql" 5 | "go.opentelemetry.io/otel/trace" 6 | 7 | apigen "github.com/42atomys/stud42/internal/api/generated" 8 | modelgen "github.com/42atomys/stud42/internal/models/generated" 9 | "github.com/42atomys/stud42/pkg/cache" 10 | ) 11 | 12 | // This file will not be regenerated automatically. 13 | // 14 | // It serves as dependency injection for your app, add any dependencies you require here. 15 | 16 | type Resolver struct { 17 | client *modelgen.Client 18 | cache *cache.Client 19 | tracer trace.Tracer 20 | } 21 | 22 | type contextKey string 23 | 24 | // NewSchema creates a graphql executable schema. 25 | func NewSchema(client *modelgen.Client, cacheClient *cache.Client, tr trace.Tracer) graphql.ExecutableSchema { 26 | return apigen.NewExecutableSchema(apigen.Config{ 27 | Resolvers: &Resolver{ 28 | client: client, 29 | cache: cacheClient, 30 | tracer: tr, 31 | }, 32 | Directives: apigen.DirectiveRoot{ 33 | AuthzByPolicy: directiveAuthzByPolicy, 34 | Authenticated: directiveAuthorization(client), 35 | }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 42_atomys 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/utils/slug.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "regexp" 4 | 5 | // SlugifyRegex is a regular expression that matches a slugified string. 6 | // It is used to validate a slugified string. It is exported so that it can be 7 | // used in other packages. 8 | var SlugifyRegex = regexp.MustCompile(`^(?:[a-z]+-?|[0-9]-?)+(= 'A' && c <= 'Z' { 22 | c += 'a' - 'A' 23 | } 24 | if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { 25 | b = append(b, c) 26 | prev = c 27 | continue 28 | } 29 | if prev != '-' { 30 | b = append(b, '-') 31 | prev = '-' 32 | } 33 | } 34 | if len(b) > 0 && b[len(b)-1] == '-' { 35 | b = b[:len(b)-1] 36 | } 37 | return string(b) 38 | } 39 | -------------------------------------------------------------------------------- /web/ui/sentry.edge.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import { BrowserTracing } from '@sentry/browser'; 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 9 | 10 | if (process.env.NODE_ENV && process.env.NODE_ENV !== 'development') { 11 | Sentry.init({ 12 | dsn: SENTRY_DSN, 13 | integrations: [new BrowserTracing()], 14 | // Adjust this value in production, or use tracesSampler for greater control 15 | tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 0, 16 | sampleRate: 1, 17 | // ... 18 | // Note: if you want to override the automatic release value, do not set a 19 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 20 | // that it will also get attached to your source maps 21 | release: `stud42@${process.env.APP_VERSION || 'dev'}`, 22 | environment: process.env.NODE_ENV, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /web/ui/sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import { BrowserTracing } from '@sentry/browser'; 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 9 | 10 | if (process.env.NODE_ENV && process.env.NODE_ENV !== 'development') { 11 | Sentry.init({ 12 | dsn: SENTRY_DSN, 13 | integrations: [new BrowserTracing()], 14 | // Adjust this value in production, or use tracesSampler for greater control 15 | tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 0, 16 | sampleRate: 1, 17 | // ... 18 | // Note: if you want to override the automatic release value, do not set a 19 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 20 | // that it will also get attached to your source maps 21 | release: `stud42@${process.env.APP_VERSION || 'dev'}`, 22 | environment: process.env.NODE_ENV, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /web/ui/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@100;300;400;500;600;700;900&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | .bg-grid-400 { 8 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(148 163 184 / 0.05)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e"); 9 | } 10 | 11 | @layer base { 12 | /* width */ 13 | ::-webkit-scrollbar { 14 | @apply w-2; 15 | } 16 | 17 | /* Track */ 18 | ::-webkit-scrollbar-track { 19 | @apply bg-slate-300; 20 | } 21 | .dark ::-webkit-scrollbar-track { 22 | @apply bg-slate-950/50; 23 | } 24 | 25 | /* Handle */ 26 | ::-webkit-scrollbar-thumb { 27 | @apply rounded-lg bg-indigo-300 transition-all cursor-pointer; 28 | } 29 | .dark ::-webkit-scrollbar-thumb { 30 | @apply bg-indigo-900; 31 | } 32 | 33 | /* Handle on hover */ 34 | ::-webkit-scrollbar-thumb:hover { 35 | @apply bg-indigo-400; 36 | } 37 | .dark ::-webkit-scrollbar-thumb:hover { 38 | @apply bg-indigo-700; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | labels: 11 | - aspect/depencencies 📦️ 12 | - aspect/dex 🤖 13 | - domain/obvious 🟩 14 | - state/triage 🚦 15 | schedule: 16 | day: sunday 17 | interval: monthly 18 | - package-ecosystem: gomod 19 | directory: / 20 | labels: 21 | - aspect/depencencies 📦️ 22 | - aspect/backend 💻 23 | - domain/obvious 🟩 24 | - state/triage 🚦 25 | schedule: 26 | day: sunday 27 | interval: monthly 28 | - package-ecosystem: npm 29 | directory: /web/ui 30 | labels: 31 | - aspect/depencencies 📦️ 32 | - aspect/interface 🕹 33 | - domain/obvious 🟩 34 | - state/triage 🚦 35 | schedule: 36 | interval: monthly 37 | -------------------------------------------------------------------------------- /web/ui/src/lib/useKeyDown.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | type UsekeyDown = (keys: string[], callback: () => void) => void; 4 | 5 | /** 6 | * The `useKeyDown` hook is used to handle keyboard shortcuts. 7 | * It accepts a list of keys and a callback function, and calls the 8 | * callback function when any of the keys are pressed. 9 | * It also prevents the default behavior of the pressed key. 10 | * The hook manage the remove of the event listener for the `keydown` event 11 | * when the component is unmounted. 12 | */ 13 | export const useKeyDown: UsekeyDown = (keys, callback) => { 14 | const onKeyDown = useCallback( 15 | (event: KeyboardEvent) => { 16 | const wasAnyKeyPressed = keys.some((key) => event.key === key); 17 | if (wasAnyKeyPressed) { 18 | event.preventDefault(); 19 | callback(); 20 | } 21 | }, 22 | [callback, keys], 23 | ); 24 | 25 | useEffect(() => { 26 | document.addEventListener('keydown', onKeyDown); 27 | return () => { 28 | document.removeEventListener('keydown', onKeyDown); 29 | }; 30 | }, [onKeyDown]); 31 | }; 32 | 33 | export default useKeyDown; 34 | -------------------------------------------------------------------------------- /internal/models/schema/notice_user.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/dialect/entsql" 8 | "entgo.io/ent/schema/edge" 9 | "entgo.io/ent/schema/field" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | // NoticesUser holds the schema definition for the NoticesUser entity. 14 | type NoticesUser struct { 15 | ent.Schema 16 | } 17 | 18 | // Fields of the NoticesUser. 19 | func (NoticesUser) Fields() []ent.Field { 20 | return []ent.Field{ 21 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 22 | Default: "uuid_generate_v4()", 23 | }), 24 | field.UUID("user_id", uuid.UUID{}), 25 | field.UUID("notice_id", uuid.UUID{}), 26 | field.Time("read_at").Optional().Nillable().Default(time.Now), 27 | } 28 | } 29 | 30 | // Edges of the NoticesUser. 31 | func (NoticesUser) Edges() []ent.Edge { 32 | return []ent.Edge{ 33 | edge.To("user", User.Type). 34 | Unique(). 35 | Required(). 36 | Field("user_id"), 37 | edge.To("notice", Notice.Type). 38 | Unique(). 39 | Required(). 40 | Field("notice_id"), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.devcontainer/postStartCommand.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROFILE_PATH="/etc/profile.d/01-devcontainer-env-sync.sh" 4 | 5 | # Load /etc/profile in /etc/zshenv 6 | echo "Sourcing /etc/profile in zsh" 7 | sudo sh -c "echo \"source /etc/profile\" > /etc/zshenv" 8 | sudo sh -c "echo \"source /etc/profile\" > /root/.zshenv" 9 | 10 | # Load the .env file 11 | echo "Loading .env file into ${PROFILE_PATH}" 12 | sudo touch ${PROFILE_PATH} 13 | while read -r line || [ -n "$line" ]; do 14 | if [ "${line#\#}" = "$line" ] && echo "$line" | grep -q "="; then 15 | key=$(echo $line | cut -d '=' -f1) 16 | echo "Sync $key from .devcontainer/.env to ${PROFILE_PATH}" 17 | # Check if key is already in ${PROFILE_PATH} 18 | if sudo grep -q "^export $key=" ${PROFILE_PATH}; then 19 | # If key is in ${PROFILE_PATH}, update the value 20 | sudo sed -i "/^export $key=/c$line" ${PROFILE_PATH} 21 | else 22 | # If key is not in ${PROFILE_PATH}, add the line 23 | echo "export $line" | sudo tee -a ${PROFILE_PATH} > /dev/null 24 | fi 25 | fi 26 | done < /workspace/.devcontainer/.env 27 | echo "Environment variables loaded" 28 | -------------------------------------------------------------------------------- /deploy/modules/sealed-secrets/variables.tf: -------------------------------------------------------------------------------- 1 | variable "enabled" { 2 | type = bool 3 | description = "Whether the module should be deployed or not" 4 | default = true 5 | } 6 | 7 | variable "sealedSecretsControllerName" { 8 | type = string 9 | description = "The name of the sealed secrets controller" 10 | default = "sealed-secrets-controller" 11 | } 12 | 13 | 14 | variable "sealedSecretsControllerNamespace" { 15 | type = string 16 | description = "The namespace of the sealed-secrets controller has deployed" 17 | default = "kube-system" 18 | } 19 | 20 | variable "sealedSecrets" { 21 | type = map(object({ 22 | namespace = optional(string, "kube-system") 23 | isClusterWide = optional(bool, false) 24 | secretType = optional(string, "Opaque") 25 | encryptedData = map(string) 26 | reflected = optional(bool, false) 27 | // Omit the following properties if reflected is true result to reflect in 28 | // all namespaces 29 | reflectedNamespaces = optional(list(string), []) 30 | })) 31 | description = "The sealed secrets to be deployed" 32 | default = {} 33 | } 34 | -------------------------------------------------------------------------------- /web/ui/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { MeWithFlagsDocument, MeWithFlagsQuery } from '@graphql.d'; 2 | import { queryAuthenticatedSSR } from '@lib/apollo'; 3 | import { NextMiddleware, NextResponse } from 'next/server'; 4 | 5 | export const middleware: NextMiddleware = async (req) => { 6 | const { pathname } = req.nextUrl.clone(); 7 | 8 | if ( 9 | pathname.startsWith('/api') || 10 | pathname.startsWith('/assets') || 11 | pathname.startsWith('/about') || 12 | pathname.startsWith('/_next') 13 | ) { 14 | return NextResponse.next(); 15 | } 16 | 17 | const { data, error } = await queryAuthenticatedSSR(req, { 18 | query: MeWithFlagsDocument, 19 | errorPolicy: 'all', 20 | }); 21 | 22 | if (pathname.startsWith('/auth')) { 23 | if (data) return NextResponse.redirect(new URL('/', req.url)); 24 | else return NextResponse.next(); 25 | } 26 | 27 | if (!data || error?.message === 'request not authenticated') { 28 | return NextResponse.redirect( 29 | new URL( 30 | '/auth/signin?callbackUrl=' + encodeURIComponent(pathname), 31 | req.url, 32 | ), 33 | ); 34 | } 35 | 36 | return NextResponse.next(); 37 | }; 38 | -------------------------------------------------------------------------------- /internal/models/gotype/account_type.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type AccountType string 10 | 11 | const ( 12 | AccountTypeOauth AccountType = "OAUTH" 13 | ) 14 | 15 | var AllAccountType = []AccountType{ 16 | AccountTypeOauth, 17 | } 18 | 19 | func (e AccountType) Values() []string { 20 | values := make([]string, len(AllAccountType)) 21 | for i := range AllAccountType { 22 | values[i] = AllAccountType[i].String() 23 | } 24 | return values 25 | } 26 | 27 | func (e AccountType) IsValid() bool { 28 | switch e { 29 | case AccountTypeOauth: 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | func (e AccountType) String() string { 36 | return string(e) 37 | } 38 | 39 | func (e *AccountType) UnmarshalGQL(v interface{}) error { 40 | str, ok := v.(string) 41 | if !ok { 42 | return fmt.Errorf("enums must be strings") 43 | } 44 | 45 | *e = AccountType(str) 46 | if !e.IsValid() { 47 | return fmt.Errorf("%s is not a valid AccountType", str) 48 | } 49 | return nil 50 | } 51 | 52 | func (e AccountType) MarshalGQL(w io.Writer) { 53 | fmt.Fprint(w, strconv.Quote(e.String())) 54 | } 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/your_issue.yml: -------------------------------------------------------------------------------- 1 | name: Your issue 2 | description: Is your issue related to anything else? Tell us by writing your own issue 3 | title: "misc: " 4 | labels: ["state/triage 🚦"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | You have a question relative to the app, need answers about development ? 10 | 11 | You can join and ask on [S42 Discord and ask](https://discord.gg/RjheCdau42) 12 | 13 | If you feel better to open an issue and the issue don't exist yet, you can write it here ! 14 | # Describe 15 | - type: textarea 16 | id: describe 17 | attributes: 18 | label: Please exprime yourself 19 | description: A clear and concise description of why you open this issue 20 | validations: 21 | required: true 22 | # Code if Conduct 23 | - type: checkboxes 24 | id: terms 25 | attributes: 26 | label: Code of Conduct 27 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/42atomys/stud42/blob/main/CODE_OF_CONDUCT.md) 28 | options: 29 | - label: I agree to follow this project's Code of Conduct 30 | required: true 31 | -------------------------------------------------------------------------------- /pkg/utils/slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | // Remove an item from a slice 6 | func Remove[T comparable](slice []T, items ...T) []T { 7 | var result = make([]T, 0) 8 | for _, element := range slice { 9 | if Contains(items, element) { 10 | continue 11 | } 12 | result = append(result, element) 13 | } 14 | return result 15 | } 16 | 17 | // Contains returns true if the item is in the slice 18 | func Contains[T comparable](slice []T, item T) bool { 19 | for _, element := range slice { 20 | if element == item { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | // Uniq returns a new slice with unique items from the given slice 28 | func Uniq[T comparable](slice []T) []T { 29 | var unique []T = make([]T, 0) 30 | for _, element := range slice { 31 | if Contains(unique, element) { 32 | continue 33 | } 34 | 35 | unique = append(unique, element) 36 | } 37 | return unique 38 | } 39 | 40 | // StringifySlice transform a slice of Stringer into a slice of string 41 | func StringifySlice[T fmt.Stringer](slice []T) []string { 42 | strSlice := make([]string, len(slice)) 43 | for i, element := range slice { 44 | strSlice[i] = element.String() 45 | } 46 | return strSlice 47 | } 48 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/configs/monitoring/loki.yaml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | common: 3 | ring: 4 | instance_addr: 127.0.0.1 5 | kvstore: 6 | store: inmemory 7 | replication_factor: 1 8 | path_prefix: /data/loki 9 | 10 | schema_config: 11 | configs: 12 | - from: 2020-05-15 13 | store: boltdb-shipper 14 | object_store: filesystem 15 | schema: v11 16 | index: 17 | prefix: loki_ 18 | period: 24h 19 | 20 | storage_config: 21 | boltdb_shipper: 22 | shared_store: filesystem 23 | active_index_directory: /data/loki/boltdb-shipper-active 24 | cache_location: /data/loki/boltdb-shipper-cache 25 | cache_ttl: 24h 26 | filesystem: 27 | directory: /data/loki/chunks 28 | retention: 29 | period: 336h # Cela correspond à 14 jours 30 | 31 | limits_config: 32 | retention_period: 336h 33 | retention_stream: 34 | - selector: '{namespace="sandbox"}' 35 | priority: 1 36 | period: 24h 37 | - selector: '{namespace="previews"}' 38 | priority: 1 39 | period: 24h 40 | 41 | memberlist: 42 | join_members: 43 | - "loki" 44 | 45 | server: 46 | grpc_listen_port: 9095 47 | http_listen_port: 3100 48 | 49 | -------------------------------------------------------------------------------- /web/ui/sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the browser. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import { BrowserTracing } from '@sentry/browser'; 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 9 | 10 | if (process.env.NODE_ENV && process.env.NODE_ENV !== 'development') { 11 | Sentry.init({ 12 | dsn: SENTRY_DSN, 13 | integrations: [ 14 | new BrowserTracing({ 15 | tracingOrigins: ['localhost:3000', 'next.s42.app', 's42.app'], 16 | }), 17 | ], 18 | // Adjust this value in production, or use tracesSampler for greater control 19 | tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 0, 20 | sampleRate: 1, 21 | // ... 22 | // Note: if you want to override the automatic release value, do not set a 23 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 24 | // that it will also get attached to your source maps 25 | release: `stud42@${process.env.APP_VERSION || 'dev'}`, 26 | environment: process.env.NODE_ENV, 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /web/ui/src/components/ColorDisplay/__tests__/ColorDisplay.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { ColorDisplay } from '../'; 3 | 4 | describe('ColorDisplay', () => { 5 | it('renders the div with the given background color', () => { 6 | const color = '#ff0000'; 7 | const { container } = render(); 8 | const divElement = container.firstChild as HTMLDivElement; 9 | expect(divElement).toHaveStyle({ 10 | backgroundColor: color, 11 | outlineColor: color, 12 | }); 13 | }); 14 | 15 | it('renders the div with transparent background color when color prop is not given', () => { 16 | const { container } = render(); 17 | const divElement = container.firstChild as HTMLDivElement; 18 | expect(divElement).toHaveStyle({ 19 | backgroundColor: 'transparent', 20 | outlineColor: '', 21 | }); 22 | }); 23 | 24 | it('renders the div with transparent background color when color prop is not a valid color', () => { 25 | const { container } = render(); 26 | const divElement = container.firstChild as HTMLDivElement; 27 | expect(divElement.style.backgroundColor).toBe(''); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /pkg/duoapi/time.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type DuoTime time.Time 10 | 11 | const duoTimeFormat = "2006-01-02 15:04:05 MST" 12 | 13 | func (dt *DuoTime) UnmarshalJSON(b []byte) error { 14 | s := strings.Trim(string(b), "\"") 15 | if s == "null" { 16 | return nil 17 | } 18 | t, err := time.Parse(duoTimeFormat, s) 19 | if err != nil { 20 | t2, err := time.Parse(time.RFC3339, s) 21 | if err != nil { 22 | return err 23 | } 24 | t = t2 25 | } 26 | *dt = DuoTime(t) 27 | return nil 28 | } 29 | 30 | func (dt DuoTime) MarshalJSON() ([]byte, error) { 31 | if dt.Time().IsZero() { 32 | return []byte("null"), nil 33 | } 34 | return json.Marshal(dt.Time()) 35 | } 36 | 37 | func (dt DuoTime) Time() time.Time { 38 | return time.Time(dt) 39 | } 40 | 41 | func (dt *DuoTime) NillableTime() *time.Time { 42 | if dt == nil { 43 | return nil 44 | } 45 | 46 | if dt.Time().IsZero() { 47 | return nil 48 | } 49 | 50 | nt := dt.Time() 51 | return &nt 52 | } 53 | 54 | func (dt DuoTime) Format(s string) string { 55 | return dt.Time().Format(s) 56 | } 57 | 58 | func (dt DuoTime) String() string { 59 | if dt.Time().IsZero() { 60 | return "" 61 | } 62 | 63 | return dt.Time().Format(time.RFC3339) 64 | } 65 | -------------------------------------------------------------------------------- /web/ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | 5 | parserOptions: { 6 | project: './tsconfig.json', 7 | ecmaVersion: 2020, 8 | ecmaFeatures: { 9 | jsx: true, 10 | }, 11 | }, 12 | 13 | extends: ['airbnb-typescript', 'next', 'next/core-web-vitals', 'prettier'], 14 | rules: { 15 | 'react/no-danger': 'off', // it's self explainatory that no-danger should be used sparingly 16 | 'react/react-in-jsx-scope': 'off', // next.js does not require react in most components 17 | 'react/prop-types': 'off', // as long as TS strict mode is off this is not required 18 | 'no-console': 'error', // no console statements allowed 19 | 'prettier/prettier': 'off', // don't show prettier errors as it will be fixed when saved anyway 20 | 'import/extensions': 'off', // don't require files extensions 21 | 'import/no-extraneous-dependencies': [ 22 | 'error', 23 | { 24 | devDependencies: ['**/*.test.*', '**/*.spec.*'], 25 | peerDependencies: true, 26 | }, 27 | ], 28 | }, 29 | settings: { 30 | react: { 31 | version: 'detect', 32 | }, 33 | }, 34 | env: { 35 | amd: true, 36 | browser: true, 37 | jest: true, 38 | node: true, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /web/ui/src/components/Name/Name.tsx: -------------------------------------------------------------------------------- 1 | import ConditionalWrapper from '@components/ConditionalWrapper'; 2 | import Tooltip from '@components/Tooltip'; 3 | import classNames from 'classnames'; 4 | import { PropsWithClassName } from 'types/globals'; 5 | import type { NameProps } from './types'; 6 | import { formatName } from './utils'; 7 | 8 | export const Name: React.FC> = (props) => { 9 | const { 10 | user = {}, 11 | displayLogin = false, 12 | className, 13 | tooltip = true, 14 | tooltipClassName, 15 | ...rProps 16 | } = props; 17 | 18 | const formattedName = formatName(user, { displayLogin }); 19 | 20 | return ( 21 | 20} 23 | trueWrapper={(children) => ( 24 | 31 | {children} 32 | 33 | )} 34 | > 35 | 36 | {formattedName} 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default Name; 43 | -------------------------------------------------------------------------------- /internal/models/schema/campus.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "entgo.io/ent/schema/index" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type Campus struct { 13 | ent.Schema 14 | } 15 | 16 | func (Campus) Fields() []ent.Field { 17 | return []ent.Field{ 18 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 19 | Default: "uuid_generate_v4()", 20 | }), 21 | field.Int("duo_id").Unique().NonNegative(), 22 | field.String("name").NotEmpty().MaxLen(255), 23 | field.String("time_zone").NotEmpty().MaxLen(255), 24 | field.String("language_code").NotEmpty().MaxLen(5), 25 | field.String("country"), 26 | field.String("zip"), 27 | field.String("city"), 28 | field.String("address"), 29 | field.String("email_extension"), 30 | field.Bool("active"), 31 | field.String("website"), 32 | field.String("twitter"), 33 | } 34 | } 35 | 36 | func (Campus) Edges() []ent.Edge { 37 | return []ent.Edge{ 38 | edge.To("locations", Location.Type), 39 | } 40 | } 41 | 42 | func (Campus) Indexes() []ent.Index { 43 | return []ent.Index{ 44 | index.Fields("duo_id").Unique(), 45 | index.Fields("name").Unique(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /deploy/modules/service/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/kubernetes" { 5 | version = "2.14.0" 6 | constraints = ">= 2.14.0" 7 | hashes = [ 8 | "h1:FFeFf2j2ipbMlrbhmIv8M7bzX3Zq8SQHeFkkQGALh1k=", 9 | "zh:1363fcd6eb3c63113eaa6947a4e7a9f78a6974ea344e89b662d97a78e2ccb70c", 10 | "zh:166352455666b7d584705ceeb00f24fb9b884ab84e3a1a6019dc45d6539c9174", 11 | "zh:4615249ce5311f6fbea9738b25b6e6159e7dcf4693b0a24bc6a5720d1bfd38d0", 12 | "zh:5205343f8e6cfa89d2f9a312edddcf263755bc294a5216555c390244df826f17", 13 | "zh:60b7d9b5da2d1a13bc9cdfe5be75da2e3d1034617dff51ef3f0beb72fe801879", 14 | "zh:61b73d78ef03f0b38ff567b78f2984089eb17724fd8d0f92943b7e522cf31e39", 15 | "zh:69dfe1278eecc6049736d74c3fa2d1f384035621ec5d72f8b180e3b25b45b592", 16 | "zh:7746656be1b437e43f7324898cd4548d7e8cad5308042ba38cb45c4fecbf38fe", 17 | "zh:7e573462091aaf2e6a37edeee33ee4d8f4c37f9a35c331e0f3a60caf078c88c1", 18 | "zh:a05e1f02b2385679087a7059944cac7fb1d71dd042601ee4d0d26e9808d14dd5", 19 | "zh:d8d5d52af1aa55160fec601a1006552d9b6fe21e97313850a1e79bc026e99cfe", 20 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /deploy/stacks/apps/variables.tf: -------------------------------------------------------------------------------- 1 | variable "namespace" { 2 | type = string 3 | description = "The namespace to deploy the application to" 4 | default = "default" 5 | } 6 | 7 | variable "appsVersion" { 8 | type = map(string) 9 | description = "The version of the application to deploy" 10 | 11 | default = { 12 | s42 = "latest" 13 | } 14 | 15 | validation { 16 | condition = alltrue([for k, v in var.appsVersion : contains(["s42"], k)]) 17 | error_message = "The appsVersion variable must contain a key for each application to be deployed" 18 | } 19 | } 20 | 21 | variable "baseUrl" { 22 | type = string 23 | description = "The base URL for the application" 24 | default = "s42.app" 25 | } 26 | variable "webhooksEnabled" { 27 | type = bool 28 | description = "Whether the webhooks workflow should be deployed or not" 29 | default = false 30 | } 31 | 32 | variable "crawlerEnabled" { 33 | type = bool 34 | description = "Whether the crawler should be deployed or not" 35 | default = false 36 | } 37 | 38 | variable "hasPersistentStorage" { 39 | type = bool 40 | description = "Whether the application should use persistent storage or not" 41 | default = false 42 | } 43 | -------------------------------------------------------------------------------- /web/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "sourceMap": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "baseUrl": "./src/", 20 | "paths": { 21 | "@components/*": ["components/*"], 22 | "@containers/*": ["containers/*"], 23 | "@ctx/*": ["contexts/*"], 24 | "@styles/*": ["styles/*"], 25 | "@pages/*": ["pages/*"], 26 | "@lib/*": ["lib/*"], 27 | "@graphql.d": ["graphql/generated.ts"], 28 | "@assets/*": ["../public/assets/*"] 29 | }, 30 | "plugins": [ 31 | { 32 | "name": "next" 33 | } 34 | ] 35 | }, 36 | "include": [ 37 | "next-env.d.ts", 38 | "**/*.ts", 39 | "**/*.tsx", 40 | "next.config.ts", 41 | "src/lib/prototypes/string.js", 42 | ".next/types/**/*.ts", 43 | "generators/*.ts" 44 | ], 45 | "exclude": ["node_modules"] 46 | } 47 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshots renders Badge unchanged 1`] = ` 4 |
5 |
9 | 10 | Testing 11 | 12 |
13 |
14 | `; 15 | 16 | exports[`snapshots renders Badge with children unchanged 1`] = ` 17 |
18 |
22 | 23 | Testing 24 | 25 |
26 |
27 | `; 28 | 29 | exports[`snapshots renders Badge with className unchanged 1`] = ` 30 |
31 |
35 | 36 | Testing 37 | 38 |
39 |
40 | `; 41 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/types.d.ts: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, Dispatch, InputHTMLAttributes } from 'react'; 2 | import { Maybe } from 'types/globals'; 3 | 4 | interface InputProps { 5 | label?: string; 6 | name: string; 7 | defaultValue?: Maybe; 8 | onChange: Dispatch; 9 | placeholder?: string; 10 | disabled?: boolean; 11 | } 12 | 13 | type InputTextType = 14 | | 'text' 15 | | 'password' 16 | | 'email' 17 | | 'number' 18 | | 'tel' 19 | | 'url' 20 | | 'search'; 21 | 22 | interface SelectInputProps extends InputProps, KeyDownEvent { 23 | name?: string; 24 | selectedValue: S; 25 | objects: S[]; 26 | } 27 | 28 | interface FileInputProps 29 | extends InputProps, 30 | KeyDownEvent, 31 | Omit< 32 | DetailedHTMLProps< 33 | InputHTMLAttributes, 34 | HTMLInputElement 35 | >, 36 | keyof InputProps | 'type' | 'value' | 'id' 37 | > {} 38 | 39 | interface TextInputProps 40 | extends InputProps, 41 | Omit, 'onChange'> { 42 | type: InputTextType; 43 | // debounce is in milliseconds (ms) if you want to use it to avoid update the 44 | // state on every key press 45 | debounce?: number; 46 | } 47 | 48 | interface SwitchProps extends InputProps { 49 | color?: string; 50 | } 51 | -------------------------------------------------------------------------------- /web/ui/src/lib/searchEngine.ts: -------------------------------------------------------------------------------- 1 | import { findCampusPerSafeLink } from '@lib/clustersMap'; 2 | 3 | /** 4 | * retrieve the cluster url for the given campus and identifier 5 | */ 6 | export const clusterURL = ( 7 | campus: string | null | undefined, 8 | identifier: string | null | undefined, 9 | ): string | null => { 10 | const campusLink = campus?.toSafeLink(); 11 | 12 | const campusData = findCampusPerSafeLink(campusLink); 13 | 14 | if (!campusData) { 15 | return null; 16 | } 17 | 18 | if (campusData && identifier) { 19 | const { clusterWithLetter } = campusData.extractor(identifier); 20 | 21 | //! FIXME : this is a hack to fix the fact that the clusterWithLetter is not 22 | //! always the same as the cluster name due to paul cluster merging 23 | //! (paul-F3A and paul-F3B are now paul-F3) and the fact that the cluster 24 | //! name is used in the url to retrieve the cluster data from the API 25 | if (clusterWithLetter.startsWith('paul') && campusLink === 'paris') { 26 | const mergedIdentifier = clusterWithLetter.slice(0, -1); 27 | return `/clusters/${campusLink}/${mergedIdentifier}?identifier=${identifier}`; 28 | } 29 | 30 | if (clusterWithLetter) { 31 | return `/clusters/${campusLink}/${clusterWithLetter}?identifier=${identifier}`; 32 | } 33 | } 34 | return null; 35 | }; 36 | -------------------------------------------------------------------------------- /internal/models/gotype/cluster_map_avatar_size.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type ClusterMapAvatarSize string 10 | 11 | const ( 12 | ClusterMapAvatarSizeAuto ClusterMapAvatarSize = "AUTO" 13 | ClusterMapAvatarSizeMedium ClusterMapAvatarSize = "MEDIUM" 14 | ClusterMapAvatarSizeLarge ClusterMapAvatarSize = "LARGE" 15 | ) 16 | 17 | var AllClusterMapAvatarSize = []ClusterMapAvatarSize{ 18 | ClusterMapAvatarSizeAuto, 19 | ClusterMapAvatarSizeMedium, 20 | ClusterMapAvatarSizeLarge, 21 | } 22 | 23 | func (e ClusterMapAvatarSize) IsValid() bool { 24 | switch e { 25 | case ClusterMapAvatarSizeAuto, ClusterMapAvatarSizeMedium, ClusterMapAvatarSizeLarge: 26 | return true 27 | } 28 | return false 29 | } 30 | 31 | func (e ClusterMapAvatarSize) String() string { 32 | return string(e) 33 | } 34 | 35 | func (e *ClusterMapAvatarSize) UnmarshalGQL(v interface{}) error { 36 | str, ok := v.(string) 37 | if !ok { 38 | return fmt.Errorf("enums must be strings") 39 | } 40 | 41 | *e = ClusterMapAvatarSize(str) 42 | if !e.IsValid() { 43 | return fmt.Errorf("%s is not a valid ClusterMapAvatarSize", str) 44 | } 45 | return nil 46 | } 47 | 48 | func (e ClusterMapAvatarSize) MarshalGQL(w io.Writer) { 49 | fmt.Fprint(w, strconv.Quote(e.String())) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/duoapi/campus_user.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | import "context" 4 | 5 | // CampusUserWebhookProcessor is the interface that must be implemented by a 6 | // webhook processor for the CampusUser model. 7 | type CampusUserWebhookProcessor interface { 8 | WebhookProcessor 9 | 10 | // Create is called when a new campusUser is created. 11 | Create(campusUser *CampusUser, metadata *WebhookMetadata) error 12 | // Update is called when a campusUser is updated. 13 | Update(campusUser *CampusUser, metadata *WebhookMetadata) error 14 | // Destroy is called when a campusUser is destroyed. 15 | Destroy(campusUser *CampusUser, metadata *WebhookMetadata) error 16 | } 17 | 18 | // HasWebhooks returns true because the CampusUser model has webhooks. 19 | func (*CampusUser) HasWebhooks() bool { 20 | return true 21 | } 22 | 23 | // ProcessWebhook processes a webhook for the CampusUser model. 24 | func (cu *CampusUser) ProcessWebhook(ctx context.Context, metadata *WebhookMetadata, processor WebhookProcessor) error { 25 | p, ok := processor.(CampusUserWebhookProcessor) 26 | if !ok { 27 | return ErrInvalidWebhookProcessor 28 | } 29 | 30 | switch metadata.Event { 31 | case "create": 32 | return p.Create(cu, metadata) 33 | case "update": 34 | return p.Update(cu, metadata) 35 | case "destroy": 36 | return p.Destroy(cu, metadata) 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/models/gotype/following_group_kind.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type FollowsGroupKind string 10 | 11 | const ( 12 | FollowsGroupKindDynamic FollowsGroupKind = "DYNAMIC" 13 | FollowsGroupKindManual FollowsGroupKind = "MANUAL" 14 | ) 15 | 16 | var AllFollowsGroupKind = []FollowsGroupKind{ 17 | FollowsGroupKindDynamic, 18 | FollowsGroupKindManual, 19 | } 20 | 21 | func (e FollowsGroupKind) Values() []string { 22 | values := make([]string, len(AllFollowsGroupKind)) 23 | for i := range AllFollowsGroupKind { 24 | values[i] = AllFollowsGroupKind[i].String() 25 | } 26 | return values 27 | } 28 | 29 | func (e FollowsGroupKind) IsValid() bool { 30 | switch e { 31 | case FollowsGroupKindDynamic, FollowsGroupKindManual: 32 | return true 33 | } 34 | return false 35 | } 36 | 37 | func (e FollowsGroupKind) String() string { 38 | return string(e) 39 | } 40 | 41 | func (e *FollowsGroupKind) UnmarshalGQL(v interface{}) error { 42 | str, ok := v.(string) 43 | if !ok { 44 | return fmt.Errorf("enums must be strings") 45 | } 46 | 47 | *e = FollowsGroupKind(str) 48 | if !e.IsValid() { 49 | return fmt.Errorf("%s is not a valid FollowsGroupKind", str) 50 | } 51 | return nil 52 | } 53 | 54 | func (e FollowsGroupKind) MarshalGQL(w io.Writer) { 55 | fmt.Fprint(w, strconv.Quote(e.String())) 56 | } 57 | -------------------------------------------------------------------------------- /web/ui/src/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Allow to extend the type of the props of a component 3 | * @typedef {Object} ClassNameProps 4 | * @property {string} className 5 | * @deprecated Use PropsWithClassName instead 6 | */ 7 | export type ClassNameProps = { 8 | className?: string; 9 | }; 10 | 11 | /** 12 | * Allow to extend the type of the props of a component 13 | * @typedef {Object} PropsWithClassName 14 | * @property {string} className 15 | * @example 16 | * import { PropsWithClassName } from 'types/globals'; 17 | * 18 | * const Component: React.FC> = ({ className, id }) => <> 19 | */ 20 | export type PropsWithClassName

= P & { className?: string }; 21 | 22 | /** 23 | * This type is used to define a type that can be null or undefined 24 | * @typedef {T | null | undefined} Maybe 25 | * @example 26 | * import { Maybe } from 'types/globals'; 27 | * const foo: Maybe = 'bar'; 28 | */ 29 | export type Maybe = T | null | undefined; 30 | 31 | declare global { 32 | interface String { 33 | equalsIgnoreCase(searchString: string): boolean; 34 | toTitleCase(): string; 35 | toSentenceCase(): string; 36 | toCamelCase(): string; 37 | removeAccents(): string; 38 | toSafeLink(): string; 39 | } 40 | 41 | interface WindowEventMap { 42 | 'local-storage': CustomEvent; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/ui/src/containers/settings/SettingsTable.tsx: -------------------------------------------------------------------------------- 1 | import { SponsorHint } from '@components/Sponsors'; 2 | import classNames from 'classnames'; 3 | import React, { Children } from 'react'; 4 | import { PropsWithClassName } from 'types/globals'; 5 | import { SettingsTableRowProps } from './types'; 6 | 7 | export const SettingsTable: React.FC< 8 | React.PropsWithChildren 9 | > = ({ children, className }) => ( 10 |

{children}
11 | ); 12 | 13 | export const SettingsTableRow: React.FC< 14 | PropsWithClassName 15 | > = ({ children, title, description, className, isSponsorOnly }) => ( 16 |
17 |
23 |
24 |
25 | {title} 26 | {isSponsorOnly && } 27 |
28 | {description && ( 29 | 30 | {description} 31 | 32 | )} 33 |
34 | {Children.map(children, (c) => ( 35 | <>{c} 36 | ))} 37 |
38 |
39 | ); 40 | -------------------------------------------------------------------------------- /internal/models/client.go: -------------------------------------------------------------------------------- 1 | package modelsutils 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/rs/zerolog/log" 8 | 9 | modelgen "github.com/42atomys/stud42/internal/models/generated" 10 | _ "github.com/42atomys/stud42/internal/models/generated/runtime" 11 | "github.com/42atomys/stud42/pkg/cache" 12 | ) 13 | 14 | var client *modelgen.Client 15 | 16 | // Connect to the database and create the client. 17 | func Connect(cacheClient *cache.Client) (err error) { 18 | var opts = []modelgen.Option{} 19 | 20 | if os.Getenv("DEBUG") == "true" { 21 | opts = append(opts, modelgen.Debug()) 22 | } 23 | 24 | if cacheClient != nil { 25 | opts = append(opts, modelgen.Cache(cacheClient)) 26 | } 27 | 28 | client, err = modelgen.Open( 29 | "postgres", 30 | os.Getenv("DATABASE_URL"), 31 | opts..., 32 | ) 33 | if err != nil { 34 | log.Error().Err(err).Msg("failed to connect to database") 35 | } 36 | return 37 | } 38 | 39 | // Client returns the client. 40 | func Client() *modelgen.Client { 41 | if client == nil { 42 | log.Fatal().Msg("client is not connected. Please call Connect() first.") 43 | } 44 | return client 45 | } 46 | 47 | // Migrate migrates the schema of the database. 48 | func Migrate() (err error) { 49 | err = client.Schema.Create(context.Background()) 50 | if err != nil { 51 | log.Error().Err(err).Msg("running schema migration") 52 | } 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /web/ui/src/components/AuthError/__tests__/AuthError.test.tsx: -------------------------------------------------------------------------------- 1 | import { AuthError } from '@components/AuthError'; 2 | import { render } from '@testing-library/react'; 3 | 4 | it('snapshot: renders Tooltip unchanged', () => { 5 | const { container } = render(); 6 | expect(container).toMatchSnapshot(); 7 | }); 8 | 9 | it('render correct data when error is an ErrorType', async () => { 10 | const container = render(); 11 | 12 | expect(container.getByText('Callback error')).toBeInTheDocument(); 13 | expect( 14 | container.getByText('An error occured while processing the callback'), 15 | ).toBeInTheDocument(); 16 | }); 17 | 18 | it('render correct data when error is not defined', async () => { 19 | const container = render(); 20 | 21 | expect(container.getByText('An error occured')).toBeInTheDocument(); 22 | expect( 23 | container.getByText('An error occured, please try again later'), 24 | ).toBeInTheDocument(); 25 | }); 26 | 27 | it('render correct data when error is not included', async () => { 28 | // @ts-ignore 29 | const container = render(); 30 | 31 | expect(container.getByText('An error occured')).toBeInTheDocument(); 32 | expect( 33 | container.getByText('An error occured, please try again later'), 34 | ).toBeInTheDocument(); 35 | }); 36 | -------------------------------------------------------------------------------- /web/ui/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 12 | 18 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /deploy/modules/cert-manager/variables.tf: -------------------------------------------------------------------------------- 1 | variable "acme_email" { 2 | type = string 3 | description = "The email address to use for ACME registration" 4 | default = "acme@s42.app" 5 | } 6 | 7 | variable "issuers" { 8 | type = map(object({ 9 | is_self_signed = bool 10 | acme_server = optional(string) 11 | private_key_secret_name = optional(string) 12 | solvers = optional(list(object({ 13 | dns01 = object({ 14 | webhook = object({ 15 | groupName = string 16 | solverName = string 17 | config = object({ 18 | endpoint = string 19 | applicationKey = string 20 | applicationSecretRef = object({ 21 | name = string 22 | key = string 23 | }) 24 | consumerKey = string 25 | }) 26 | }) 27 | }) 28 | }))) 29 | })) 30 | description = "The list of certificates to create" 31 | } 32 | 33 | variable "certificates" { 34 | type = map(object({ 35 | namespace = optional(string) 36 | dns_names = list(string) 37 | issuer_kind = optional(string) 38 | issuer_name = string 39 | secret_name = optional(string) 40 | duration = optional(string) 41 | renew_before = optional(string) 42 | })) 43 | description = "The certificates to create" 44 | } 45 | -------------------------------------------------------------------------------- /deploy/stacks/cluster/istio.tf: -------------------------------------------------------------------------------- 1 | module "istio" { 2 | source = "../../modules/istio" 3 | 4 | gateways = { 5 | "app-s42" = { 6 | ingressSelectorName = "ingressgateway" 7 | namespace = "production" 8 | serverHttpsRedirect = true 9 | hosts = ["s42.app"] 10 | tlsMode = "SIMPLE" 11 | tlsCredentialName = "app-s42-tls" 12 | }, 13 | "app-s42-next" = { 14 | ingressSelectorName = "ingressgateway" 15 | namespace = "staging" 16 | serverHttpsRedirect = true 17 | hosts = ["next.s42.app"] 18 | tlsMode = "SIMPLE" 19 | tlsCredentialName = "app-s42-next-tls" 20 | }, 21 | "dev-s42-previews" = { 22 | ingressSelectorName = "ingressgateway" 23 | namespace = "previews" 24 | serverHttpsRedirect = true 25 | hosts = ["*.previews.s42.dev"] 26 | tlsMode = "SIMPLE" 27 | tlsCredentialName = "dev-s42-previews-tls" 28 | }, 29 | "app-s42-dashboards" = { 30 | ingressSelectorName = "ingressgateway" 31 | namespace = local.monitoringNamespace 32 | serverHttpsRedirect = true 33 | hosts = ["dashboards.s42.app"] 34 | tlsMode = "SIMPLE" 35 | tlsCredentialName = "app-s42-dashboards-tls" 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # ================================================= 4 | # Globals 🤖 5 | # ================================================= 6 | # local env files 7 | .env* 8 | 9 | # Configuration Files 10 | config/* 11 | web/ui/stud42.config.yaml 12 | 13 | # ================================================= 14 | # Backend ignores 💻 15 | # ================================================= 16 | 17 | # Certs 18 | certs/ 19 | 20 | # Generated code 21 | internal/api/generated/* 22 | internal/models/generated/* 23 | internal/models/structs_generated.go 24 | internal/**/*.pb.go 25 | 26 | # ================================================= 27 | # Frontned ignores 🕹 28 | # ================================================= 29 | 30 | # Generated code 31 | web/ui/src/graphql/schema.json 32 | web/ui/src/graphql/generated.ts 33 | 34 | # dependencies 35 | web/ui/node_modules 36 | web/ui/*.pnp 37 | web/ui/*.pnp.js 38 | 39 | # testing 40 | web/ui/coverage 41 | 42 | # next.js 43 | web/ui/.next/ 44 | web/ui/out/ 45 | 46 | # production 47 | web/ui/build 48 | 49 | # misc 50 | deploy/ 51 | docs/ 52 | config/* 53 | tools/ 54 | .github 55 | .DS_Store 56 | *.pem 57 | 58 | # debug 59 | web/ui/*npm-debug.log* 60 | web/ui/*yarn-debug.log* 61 | web/ui/*yarn-error.log* 62 | 63 | # vercel 64 | web/ui/*.vercel 65 | 66 | # typescript 67 | web/ui/**.tsbuildinfo 68 | -------------------------------------------------------------------------------- /web/ui/src/lib/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react'; 2 | 3 | // UseDebounceFunc is a function that takes a callback and a delay in milliseconds 4 | // this an overload of useState that returns a tuple of [value, setValue] 5 | // the value is the debounced value and the setValue is the function to set the value 6 | type UseDebounceFunc = ( 7 | initialValue: S | (() => S), 8 | delay: number, 9 | ) => [S, Dispatch>]; 10 | 11 | // useDebounce is a hook that takes an initial value like useState and a delay in 12 | // milliseconds. It returns a tuple of [value, setValue] where the value is the 13 | // debounced value and the setValue is the function to set the value 14 | // 15 | // When the delay is reached, the current value is sended to the debounced 16 | // value. 17 | // 18 | // TL;DR / usage: 19 | // const [debouncedValue, setValue] = useDebounce(initialValue, delay) 20 | const useDebounce: UseDebounceFunc = (initialValue, delay = 250) => { 21 | const [actualValue, setActualValue] = useState(initialValue); 22 | const [debounceValue, setDebounceValue] = useState(initialValue); 23 | useEffect(() => { 24 | const debounceId = setTimeout(() => setDebounceValue(actualValue), delay); 25 | return () => clearTimeout(debounceId); 26 | }, [actualValue, delay]); 27 | return [debounceValue, setActualValue]; 28 | }; 29 | 30 | export default useDebounce; 31 | -------------------------------------------------------------------------------- /web/ui/src/contexts/types.d.ts: -------------------------------------------------------------------------------- 1 | import { NotificationProps } from '@components/Notification/types'; 2 | import { MeQuery, SettingsInput } from '@graphql.d'; 3 | import { Session } from 'next-auth'; 4 | 5 | export interface MeContextValue extends Omit { 6 | // Function to update the current user in the session storage 7 | refetchMe: () => Promise>; 8 | // Function that takes an entity object or string 9 | // and returns true or false depending on whether the entity is the current 10 | // user or not. 11 | isMe: (entity: Pick | string) => boolean; 12 | // Function that takes an entity object or 13 | // string and returns true or false depending on whether the entity is followed 14 | // by the current user or not. 15 | isFollowed: (entity: Pick | string) => boolean; 16 | // Function that takes the new settings and updates the current user settings 17 | // in the database and refetches the current user. 18 | updateSettings: (settings: Partial) => Promise; 19 | } 20 | 21 | type MeProviderProps = React.PropsWithChildren<{ 22 | apolloClient?: ApolloClient; 23 | session?: Maybe; 24 | }>; 25 | 26 | export interface NotificationContextValue { 27 | notificationsCount: number; 28 | addNotification: (notification: Omit) => void; 29 | removeNotification: (notification: NotificationProps) => void; 30 | } 31 | -------------------------------------------------------------------------------- /web/ui/src/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A type that takes a generic type T and excludes the null and undefined values 3 | * from it. The resulting type is a subtype of T that guarantees that the value 4 | * is not null or undefined. 5 | */ 6 | export type NonNullable = Exclude; 7 | 8 | /** 9 | * A type that takes a generic type T and makes all its properties optional, 10 | * including nested properties. 11 | * It creates a new type that recursively copies the structure of T with all 12 | * properties marked as optional using the ? symbol. 13 | */ 14 | export type NestedPartial = { 15 | [P in keyof T]?: NestedPartial; 16 | }; 17 | 18 | /** 19 | * A type that takes two generic types T and U and returns a type that 20 | * represents their exclusive or (XOR) operation. 21 | * The resulting type is either T or U, but not both or neither. If both types 22 | * overlap (i.e., they have common properties), the resulting type is never. 23 | */ 24 | export type XOR = T | U extends T & U ? never : T | U; 25 | 26 | /** 27 | * A type that takes a generic type T (defaulting to string) and defines an 28 | * object that has an optional link property, which is a function that takes an 29 | * optional obj of type T and returns a URL object. 30 | * The T type parameter allows specifying the type of the object that the link 31 | * function receives as input. 32 | */ 33 | export type ClickableLink = { 34 | link?: (obj: T) => URL; 35 | }; 36 | -------------------------------------------------------------------------------- /web/ui/src/components/Emoji/Emoji.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import React from 'react'; 3 | import { Props } from './types'; 4 | 5 | const codepoints = (char: string): string | undefined => { 6 | return char.codePointAt(0)?.toString(16); 7 | }; 8 | 9 | /** 10 | * Emoji is a component that renders an emoji from unicode to 11 | * Twitter's emoji SVG file. 12 | * 13 | * @param {string} emoji - The emoji to render 14 | * @param {number} size - The size of the emoji in pixels 15 | * @param {string} containerClassName - The class name to apply to the container 16 | */ 17 | export const Emoji = ({ 18 | emoji = '', 19 | size = 16, 20 | containerClassName, 21 | ...props 22 | }: Props) => { 23 | var emojiCode: string[] = []; 24 | for (let c of emoji) { 25 | let code = codepoints(c); 26 | if (code) emojiCode.push(code); 27 | } 28 | 29 | if (emojiCode.length < 1) return null; 30 | 31 | const hasContainer = containerClassName ? true : false; 32 | const containerProps = hasContainer 33 | ? { className: containerClassName } 34 | : null; 35 | 36 | return React.createElement( 37 | hasContainer ? 'span' : React.Fragment, 38 | // @ts-ignore 39 | containerProps, 40 | {emoji}, 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /deploy/modules/cert-manager/issuers.tf: -------------------------------------------------------------------------------- 1 | resource "kubectl_manifest" "issuers" { 2 | for_each = { for key, value in var.issuers : key => value if !value.is_self_signed } 3 | depends_on = [ 4 | helm_release.cert_manager, 5 | helm_release.cert_manager_ovh 6 | ] 7 | 8 | yaml_body = yamlencode( 9 | { 10 | apiVersion = "cert-manager.io/v1" 11 | kind = "ClusterIssuer" 12 | metadata = { 13 | name = each.key 14 | } 15 | 16 | spec = { 17 | selfSigned = each.value.is_self_signed ? {} : null 18 | acme = { 19 | email = var.acme_email 20 | server = each.value.acme_server 21 | privateKeySecretRef = { 22 | name = each.value.private_key_secret_name 23 | } 24 | solvers = each.value.solvers 25 | } 26 | } 27 | } 28 | ) 29 | } 30 | 31 | resource "kubectl_manifest" "self_signed_issuers" { 32 | for_each = { for key, value in var.issuers : key => value if value.is_self_signed } 33 | depends_on = [ 34 | helm_release.cert_manager, 35 | helm_release.cert_manager_ovh 36 | ] 37 | 38 | yaml_body = yamlencode( 39 | { 40 | apiVersion = "cert-manager.io/v1" 41 | kind = "ClusterIssuer" 42 | metadata = { 43 | name = each.key 44 | } 45 | 46 | spec = { 47 | selfSigned = each.value.is_self_signed ? {} : null 48 | } 49 | } 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /deploy/stacks/apps/configs/stud42/stud42.yaml.tftpl: -------------------------------------------------------------------------------- 1 | # API relatives configurations 2 | api: 3 | s3: 4 | users: 5 | bucket: s42-users 6 | region: gra 7 | endpoint: https://s3.gra.io.cloud.ovh.net 8 | forcePathStyle: false 9 | exports: 10 | bucket: s42-exports 11 | region: gra 12 | endpoint: https://s3.gra.io.cloud.ovh.net 13 | forcePathStyle: false 14 | 15 | 16 | # Interface relatives configurations 17 | interface: {} 18 | 19 | # auth service relatives configurations 20 | auth: 21 | # Endpoint of the public JWKSet can be used to validate 22 | # a JWT Token 23 | endpoints: 24 | sets: https://${rootDomain}/.well-known/jwks 25 | sign: https://${rootDomain}/auth/token 26 | # Configuration about the JWT 27 | # Also called : The JWK 28 | jwk: 29 | # The issuer of the JWT token (the auth service) should be 30 | # the same as the one configured in the auth service. This 31 | # is used to validate the JWT token. 32 | issuer: "s42-id-provider" 33 | # The audience of the JWT token (the app) should be the same 34 | # as the one configured in the auth service. This is used to 35 | # validate the JWT token too. 36 | audience: "app:s42:system" 37 | # Certificates used to sign and validate the JWT token. 38 | certPrivateKeyFile: /etc/certs/jwk/private.key 39 | certPublicKeyFile: /etc/certs/jwk/public.pem 40 | 41 | discord: 42 | guildID: "248936708379246593" 43 | -------------------------------------------------------------------------------- /internal/models/gotype/user_pronouns.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type UserPronoun string 10 | 11 | const ( 12 | UserPronounPrivate UserPronoun = "PRIVATE" 13 | UserPronounHeHim UserPronoun = "HE_HIM" 14 | UserPronounSheHer UserPronoun = "SHE_HER" 15 | UserPronounTheyThem UserPronoun = "THEY_THEM" 16 | ) 17 | 18 | var AllUserPronoun = []UserPronoun{ 19 | UserPronounPrivate, 20 | UserPronounHeHim, 21 | UserPronounSheHer, 22 | UserPronounTheyThem, 23 | } 24 | 25 | func (e UserPronoun) Values() []string { 26 | values := make([]string, len(AllUserPronoun)) 27 | for i := range AllUserPronoun { 28 | values[i] = AllUserPronoun[i].String() 29 | } 30 | return values 31 | } 32 | 33 | func (e UserPronoun) IsValid() bool { 34 | switch e { 35 | case UserPronounPrivate, UserPronounHeHim, UserPronounSheHer, UserPronounTheyThem: 36 | return true 37 | } 38 | return false 39 | } 40 | 41 | func (e UserPronoun) String() string { 42 | return string(e) 43 | } 44 | 45 | func (e *UserPronoun) UnmarshalGQL(v interface{}) error { 46 | str, ok := v.(string) 47 | if !ok { 48 | return fmt.Errorf("enums must be strings") 49 | } 50 | 51 | *e = UserPronoun(str) 52 | if !e.IsValid() { 53 | return fmt.Errorf("%s is not a valid UserPronoun", str) 54 | } 55 | return nil 56 | } 57 | 58 | func (e UserPronoun) MarshalGQL(w io.Writer) { 59 | fmt.Fprint(w, strconv.Quote(e.String())) 60 | } 61 | -------------------------------------------------------------------------------- /internal/models/gotype/user_flags.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type UserFlag string 10 | 11 | const ( 12 | UserFlagStaff UserFlag = "STAFF" 13 | UserFlagCollaborator UserFlag = "COLLABORATOR" 14 | UserFlagContributor UserFlag = "CONTRIBUTOR" 15 | UserFlagSponsor UserFlag = "SPONSOR" 16 | UserFlagStargazer UserFlag = "STARGAZER" 17 | UserFlagBeta UserFlag = "BETA" 18 | UserFlagDiscord UserFlag = "DISCORD" 19 | ) 20 | 21 | var ( 22 | UserAllFlag = []UserFlag{ 23 | UserFlagStaff, 24 | UserFlagCollaborator, 25 | UserFlagContributor, 26 | UserFlagSponsor, 27 | UserFlagStargazer, 28 | UserFlagBeta, 29 | UserFlagDiscord, 30 | } 31 | 32 | DefaultUserFlag = []UserFlag{} 33 | ) 34 | 35 | func (e UserFlag) IsValid() bool { 36 | switch e { 37 | case UserFlagStaff, UserFlagCollaborator, UserFlagContributor, 38 | UserFlagSponsor, UserFlagStargazer, UserFlagBeta, UserFlagDiscord: 39 | return true 40 | } 41 | return false 42 | } 43 | 44 | func (e UserFlag) String() string { 45 | return string(e) 46 | } 47 | 48 | func (e *UserFlag) UnmarshalGQL(v interface{}) error { 49 | str, ok := v.(string) 50 | if !ok { 51 | return fmt.Errorf("enums must be strings") 52 | } 53 | 54 | *e = UserFlag(str) 55 | if !e.IsValid() { 56 | return fmt.Errorf("%s is not a valid UserFlag", str) 57 | } 58 | return nil 59 | } 60 | 61 | func (e UserFlag) MarshalGQL(w io.Writer) { 62 | fmt.Fprint(w, strconv.Quote(e.String())) 63 | } 64 | -------------------------------------------------------------------------------- /web/ui/src/components/Notification/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | 3 | export type NotificationProps = { 4 | // id of the notification. Provided by the addNotification function. 5 | id: string; 6 | // type of the notification. Define the style and color of the notification. 7 | type: 'error' | 'warning' | 'success' | 'info' | 'default'; 8 | // title of the notification. Displayed on the top of the notification. 9 | title: string; 10 | // message of the notification. Displayed on the bottom of the notification. 11 | message: string; 12 | // duration of the notification. Define the time the notification is displayed. 13 | // The minimum value is 4000 (4s). 14 | // If the duration is 0, the notification will be displayed until the user 15 | // click on the close button. 16 | duration?: number; 17 | // children of the notification. Let you add some custom content to the 18 | // notification. `a` and `button` are styled automatically by the notification. 19 | children?: React.ReactNode; 20 | }; 21 | 22 | // NotificationComponent represent the component that will be displayed in the 23 | // notification system. 24 | export type NotificationComponent = (props: NotificationProps) => JSX.Element; 25 | 26 | // NotificationContextType is the type of the context that will be used to 27 | // manage the notifications. 28 | export type NotificationContextType = { 29 | notifications: NotificationProps[]; 30 | setNotifications: Dispatch>; 31 | }; 32 | -------------------------------------------------------------------------------- /pkg/cache/gql.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | ) 8 | 9 | type GQLCache struct { 10 | *TypedClient[any] 11 | ttl time.Duration 12 | } 13 | 14 | var ( 15 | // gqlCacheKey is the key used to store the query cache usef internally on 16 | // NewGQLCache type 17 | gqlCacheKey = NewKeyBuilder().WithPrefix("s42-gql-cache") 18 | ) 19 | 20 | // NewGQLCache creates a new cache client for the graphql layer with the given 21 | // ttl for the cache. 22 | func (c *Client) NewGQLCache(ttl time.Duration) (*GQLCache, error) { 23 | return &GQLCache{TypedClient: New[any](c), ttl: ttl}, nil 24 | } 25 | 26 | // Add adds a new value to the cache with the given key. 27 | // This is based on the [GQLGen Doc] 28 | // 29 | // [GQLGen Doc]: https://github.com/99designs/gqlgen/blob/master/graphql/cache.go 30 | func (c *GQLCache) Add(ctx context.Context, key string, value interface{}) { 31 | err := c.Set(ctx, gqlCacheKey.WithObject(key).Build(), value, WithExpiration(c.ttl)) 32 | if err != nil { 33 | log.Printf("failed to add to cache: %s", err.Error()) 34 | } 35 | } 36 | 37 | // Get gets a value from the cache with the given key. 38 | // This is based on the [GQLGen Doc] 39 | // 40 | // [GQLGen Doc]: https://github.com/99designs/gqlgen/blob/master/graphql/cache.go 41 | func (c *GQLCache) Get(ctx context.Context, key string) (interface{}, bool) { 42 | s, err := c.TypedClient.Get(ctx, gqlCacheKey.WithObject(key).Build()) 43 | if err != nil { 44 | return struct{}{}, false 45 | } 46 | 47 | return s, true 48 | } 49 | -------------------------------------------------------------------------------- /web/ui/src/components/Soon/Soon.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Soon is a component that shows a soon animation for part not yet implemented 3 | */ 4 | export const Soon = () => { 5 | return ( 6 |
7 |

8 | 18 | 19 | Under construction 20 |

21 | 22 | This part is not developed yet. Will be available during the beta phase.{' '} 23 |
24 | You can help us by contributing to the idea on Discord. 25 |
26 | 32 | Talk about it on Discord 33 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /web/ui/src/components/NewFeaturePing/NewFeaturePing.tsx: -------------------------------------------------------------------------------- 1 | import Tooltip from '@components/Tooltip'; 2 | import { LocalStorageKeys } from '@lib/storageKeys'; 3 | import useLocalStorage from '@lib/useLocalStorage'; 4 | 5 | // To use the ping component, the parent of the component must be have the 6 | // followings className: "relative flex items-stretch" 7 | export const NewFeaturePing = ({ featureName }: { featureName: string }) => { 8 | const [visible, setVisible] = useLocalStorage( 9 | LocalStorageKeys.NewFeatureReadStatus(featureName), 10 | true, 11 | ); 12 | if (!visible) { 13 | // Return div to prevent mismatch error between server side component and 14 | // client side component 15 | return
; 16 | } 17 | 18 | return ( 19 | 28 | setVisible(false)} 31 | > 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default NewFeaturePing; 40 | -------------------------------------------------------------------------------- /internal/models/uuid_test.go: -------------------------------------------------------------------------------- 1 | package modelsutils 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | func TestUnmarshalUUID(t *testing.T) { 12 | 13 | tests := []struct { 14 | name string 15 | input interface{} 16 | want uuid.UUID 17 | wantErr bool 18 | }{ 19 | {"valid uuid", "a0a0a0a0-0a0a-0a0a-0a0a-0a0a0a0a0a0a", uuid.MustParse("a0a0a0a0-0a0a-0a0a-0a0a-0a0a0a0a0a0a"), false}, 20 | {"invalid uuid", "invalid", uuid.UUID{}, true}, 21 | {"invalid type passed", 123, uuid.UUID{}, true}, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | got, err := UnmarshalUUID(tt.input) 26 | if (err != nil) != tt.wantErr { 27 | t.Errorf("UnmarshalUUID() error = %v, wantErr %v", err, tt.wantErr) 28 | return 29 | } 30 | if !reflect.DeepEqual(got, tt.want) { 31 | t.Errorf("UnmarshalUUID() = %v, want %v", got, tt.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestMarshalUUID(t *testing.T) { 38 | tests := []struct { 39 | name string 40 | input uuid.UUID 41 | want string 42 | }{ 43 | {"valid uuid", uuid.MustParse("a0a0a0a0-0a0a-0a0a-0a0a-0a0a0a0a0a0a"), "\"a0a0a0a0-0a0a-0a0a-0a0a-0a0a0a0a0a0a\""}, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | var b bytes.Buffer 48 | 49 | got := MarshalUUID(tt.input) 50 | got.MarshalGQL(&b) 51 | 52 | if !reflect.DeepEqual(b.String(), tt.want) { 53 | t.Errorf("MarshalUUID() = %v, want %v", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/logging.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "runtime/debug" 6 | "time" 7 | 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // responseWriter is a minimal wrapper for http.ResponseWriter that allows the 12 | // written HTTP status code to be captured for logging. 13 | type responseWriter struct { 14 | http.ResponseWriter 15 | status int 16 | wroteHeader bool 17 | } 18 | 19 | func wrapResponseWriter(w http.ResponseWriter) *responseWriter { 20 | return &responseWriter{ResponseWriter: w} 21 | } 22 | 23 | func (rw *responseWriter) Status() int { 24 | return rw.status 25 | } 26 | 27 | func (rw *responseWriter) WriteHeader(code int) { 28 | if rw.wroteHeader { 29 | return 30 | } 31 | 32 | rw.status = code 33 | rw.ResponseWriter.WriteHeader(code) 34 | rw.wroteHeader = true 35 | } 36 | 37 | // LoggingMiddleware logs the incoming HTTP request & its duration. 38 | func LoggingMiddleware(next http.Handler) http.Handler { 39 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | defer func() { 41 | if err := recover(); err != nil { 42 | w.WriteHeader(http.StatusInternalServerError) 43 | log.Error().Err(err.(error)).Interface("trace", debug.Stack()).Msg("") 44 | } 45 | }() 46 | 47 | start := time.Now() 48 | wrapped := wrapResponseWriter(w) 49 | next.ServeHTTP(wrapped, r) 50 | log.Debug(). 51 | Str("method", r.Method). 52 | Int("status", wrapped.status). 53 | Str("path", r.URL.EscapedPath()). 54 | Dur("duration", time.Since(start)). 55 | Msg("request processed") 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /deploy/stacks/pre-cluster/kubernetes.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | namespaces = { 3 | "cert-manager" = { 4 | alias : [] 5 | istioInjection : false 6 | }, 7 | "istio-system" = { 8 | alias : [] 9 | istioInjection : true 10 | }, 11 | "monitoring" = { 12 | alias : [] 13 | istioInjection : true 14 | }, 15 | "permission-manager" = { 16 | alias : [] 17 | istioInjection : false 18 | }, 19 | "production" = { 20 | alias : ["live"] 21 | istioInjection : true 22 | }, 23 | "previews" = { 24 | alias : ["reviews", "review-apps", "pull-requests"] 25 | istioInjection : true 26 | }, 27 | "sandbox" = { 28 | alias : ["dev"] 29 | istioInjection : true 30 | }, 31 | "staging" = { 32 | alias : ["next"] 33 | istioInjection : true 34 | } 35 | } 36 | } 37 | 38 | resource "kubernetes_namespace" "namespace" { 39 | for_each = { for key, value in local.namespaces : key => value } 40 | 41 | metadata { 42 | name = each.key 43 | labels = { 44 | "app.kubernetes.io/name" = each.key 45 | "app.kubernetes.io/alias" = join(".", each.value.alias) 46 | "app.kubernetes.io/managed-by" = "terraform" 47 | "istio-injection" = each.value.istioInjection ? "enabled" : "disabled" 48 | "pod-security.kubernetes.io/warn" = "restricted" 49 | "pod-security.kubernetes.io/warn-version" = "v1.23" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/models/gotype/notice_color.go: -------------------------------------------------------------------------------- 1 | package gotype 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type NoticeColor string 10 | 11 | const ( 12 | NoticeColorInfo NoticeColor = "INFO" 13 | NoticeColorSuccess NoticeColor = "SUCCESS" 14 | NoticeColorWarning NoticeColor = "WARNING" 15 | NoticeColorDanger NoticeColor = "DANGER" 16 | NoticeColorBlack NoticeColor = "BLACK" 17 | ) 18 | 19 | var AllNoticeColor = []NoticeColor{ 20 | NoticeColorInfo, 21 | NoticeColorSuccess, 22 | NoticeColorWarning, 23 | NoticeColorDanger, 24 | NoticeColorBlack, 25 | } 26 | 27 | func (e NoticeColor) Values() []string { 28 | values := make([]string, len(AllNoticeColor)) 29 | for i := range AllNoticeColor { 30 | values[i] = AllNoticeColor[i].String() 31 | } 32 | return values 33 | } 34 | 35 | func (e NoticeColor) IsValid() bool { 36 | switch e { 37 | case NoticeColorInfo, NoticeColorSuccess, NoticeColorWarning, NoticeColorDanger, NoticeColorBlack: 38 | return true 39 | } 40 | return false 41 | } 42 | 43 | func (e NoticeColor) String() string { 44 | return string(e) 45 | } 46 | 47 | func (e *NoticeColor) UnmarshalGQL(v interface{}) error { 48 | str, ok := v.(string) 49 | if !ok { 50 | return fmt.Errorf("enums must be strings") 51 | } 52 | 53 | *e = NoticeColor(str) 54 | if !e.IsValid() { 55 | return fmt.Errorf("%s is not a valid NoticeColor", str) 56 | } 57 | return nil 58 | } 59 | 60 | func (e NoticeColor) MarshalGQL(w io.Writer) { 61 | fmt.Fprint(w, strconv.Quote(e.String())) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/utils/random_color_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetRandomHexColor(t *testing.T) { 11 | assert := assert.New(t) 12 | 13 | // Test that the result starts with "#" character 14 | randomHexColor := GetRandomHexColor() 15 | assert.True(string(randomHexColor[0]) == "#") 16 | 17 | // Test that the result has a length of 7 characters 18 | assert.Equal(7, len(randomHexColor)) 19 | 20 | // Test that the result is a valid hexadecimal value 21 | assert.Regexp(regexp.MustCompile("^#[0-9a-fA-F]{6}$"), randomHexColor) 22 | 23 | // Test that the function does not panic when called 24 | assert.NotPanics(func() { GetRandomHexColor() }) 25 | 26 | // Test that the function uses the crypto/rand library to generate random bytes 27 | assert.NotEqual(GetRandomHexColor(), GetRandomHexColor()) 28 | } 29 | 30 | func TestGetRandomRBGColor(t *testing.T) { 31 | assert := assert.New(t) 32 | 33 | // Test that the function does not panic when called 34 | assert.NotPanics(func() { GetRandomRBGColor() }) 35 | 36 | // Test that the function returns a valid RGBColor struct 37 | randomRGBColor := GetRandomRBGColor() 38 | assert.True(randomRGBColor.Red >= 0 && randomRGBColor.Red <= 255) 39 | assert.True(randomRGBColor.Green >= 0 && randomRGBColor.Green <= 255) 40 | assert.True(randomRGBColor.Blue >= 0 && randomRGBColor.Blue <= 255) 41 | 42 | // Test that the function uses the crypto/rand library to generate random bytes 43 | assert.NotEqual(GetRandomRBGColor(), GetRandomRBGColor()) 44 | } 45 | -------------------------------------------------------------------------------- /internal/models/schema/account.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "entgo.io/ent/schema/index" 9 | "github.com/42atomys/stud42/internal/models/gotype" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type Account struct { 14 | ent.Schema 15 | } 16 | 17 | func (Account) Fields() []ent.Field { 18 | return []ent.Field{ 19 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 20 | Default: "uuid_generate_v4()", 21 | }), 22 | field.Enum("type").GoType(gotype.AccountType("")), 23 | field.Enum("provider").GoType(gotype.AccountProvider("")), 24 | field.String("provider_account_id").NotEmpty().MaxLen(255), 25 | field.String("username").NotEmpty().MaxLen(255), 26 | field.Int("expires_at").Optional().Nillable(), 27 | field.String("token_type").NotEmpty().MaxLen(255), 28 | field.String("refresh_token").Unique().Optional().Nillable(), 29 | field.String("access_token").Unique().NotEmpty(), 30 | field.String("scope").NotEmpty().MaxLen(255), 31 | field.UUID("user_id", uuid.UUID{}), 32 | field.Bool("public").Default(true), 33 | } 34 | } 35 | 36 | func (Account) Edges() []ent.Edge { 37 | return []ent.Edge{ 38 | edge.From("user", User.Type).Required().Field("user_id").Ref("accounts").Unique(), 39 | } 40 | } 41 | 42 | func (Account) Indexes() []ent.Index { 43 | return []ent.Index{ 44 | index.Fields("provider", "provider_account_id").Unique(), 45 | index.Edges("user").Fields("provider").Unique(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/ui/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { tv } from 'tailwind-variants'; 3 | import type { ButtonProps } from './types'; 4 | 5 | const variants = tv({ 6 | base: 'inline-flex justify-center rounded-md font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 sm:col-start-2 disabled:opacity-50 disabled:pointer-events-none', 7 | 8 | variants: { 9 | size: { 10 | sm: 'px-2.5 py-1.5 text-xs', 11 | md: 'px-3 py-2 text-sm', 12 | lg: 'px-4 py-2 text-sm', 13 | xl: 'px-4 py-2 text-base', 14 | }, 15 | 16 | color: { 17 | primary: 18 | 'bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-500', 19 | secondary: 'bg-slate-600 hover:bg-slate-500 focus-visible:ring-slate-500', 20 | danger: 'bg-red-600 hover:bg-red-500 focus-visible:ring-red-500', 21 | success: 'bg-green-600 hover:bg-green-500 focus-visible:ring-green-500', 22 | warning: 23 | 'bg-yellow-600 hover:bg-yellow-500 focus-visible:ring-yellow-500', 24 | info: 'bg-blue-600 hover:bg-blue-500 focus-visible:ring-blue-500', 25 | black: 'bg-black hover:bg-slate-950 focus-visible:ring-slate-950', 26 | }, 27 | }, 28 | 29 | defaultVariants: { 30 | size: 'md', 31 | color: 'primary', 32 | }, 33 | }); 34 | 35 | export const Button: React.FC> = ({ 36 | children, 37 | color, 38 | size, 39 | ...props 40 | }) => ( 41 | 44 | ); 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # ================================================= 4 | # Globals 🤖 5 | # ================================================= 6 | # local env files 7 | .env* 8 | !.env.example 9 | 10 | # sensitive data 11 | .devcontainer/codespace_workaround 12 | .devcontainer/.env 13 | 14 | # Binaries 15 | bin/rabbitmqadmin 16 | bin/mc 17 | 18 | # Configuration Files 19 | config/* 20 | !config/*example.yaml 21 | 22 | # Terraform 23 | .terraform 24 | 25 | # ================================================= 26 | # Backend ignores 💻 27 | # ================================================= 28 | 29 | # Certs 30 | certs/ 31 | 32 | # Generated code 33 | internal/api/generated/* 34 | internal/models/generated/* 35 | internal/models/structs_generated.go 36 | internal/**/*.pb.go 37 | 38 | # testing 39 | coverage.out 40 | 41 | # ================================================= 42 | # Frontned ignores 🕹 43 | # ================================================= 44 | 45 | # Generated code 46 | web/ui/src/graphql/schema.json 47 | web/ui/src/graphql/generated.ts 48 | 49 | # dependencies 50 | web/ui/node_modules 51 | web/ui/*.pnp 52 | web/ui/*.pnp.js 53 | 54 | # testing 55 | web/ui/coverage 56 | 57 | # next.js 58 | web/ui/.next/ 59 | web/ui/out/ 60 | 61 | # production 62 | web/ui/build 63 | 64 | # misc 65 | .DS_Store 66 | *.pem 67 | 68 | # debug 69 | web/ui/*npm-debug.log* 70 | web/ui/*yarn-debug.log* 71 | web/ui/*yarn-error.log* 72 | 73 | # vercel 74 | web/ui/*.vercel 75 | 76 | # typescript 77 | web/ui/**.tsbuildinfo 78 | -------------------------------------------------------------------------------- /internal/pkg/searchengine/meilisearch.go: -------------------------------------------------------------------------------- 1 | package searchengine 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/meilisearch/meilisearch-go" 7 | "github.com/rs/zerolog/log" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type Client struct { 12 | *meilisearch.Client 13 | } 14 | 15 | // initMeilisearchDependency initializes the MeiliSearch client and ensures that 16 | // all the indexes exist and are configured correctly. 17 | func initMeilisearchDependency() { 18 | if err := NewClient().EnsureAllIndexes(); err != nil { 19 | log.Fatal().Err(err).Msg("failed to ensure all indexes") 20 | } 21 | } 22 | 23 | // NewClient creates a new MeiliSearch client and returns it. 24 | // It uses the configuration to get the host and the API key. 25 | func NewClient() *Client { 26 | var host, token string 27 | if host = viper.GetString("searchengine.meilisearch.host"); host == "" { 28 | log.Fatal().Msg("searchengine.meilisearch.host not set") 29 | } 30 | 31 | if token = viper.GetString("searchengine.meilisearch.token"); token == "" { 32 | log.Fatal().Msg("searchengine.meilisearch.token not set") 33 | } 34 | 35 | return &Client{ 36 | Client: meilisearch.NewClient(meilisearch.ClientConfig{ 37 | Host: host, 38 | APIKey: token, 39 | }), 40 | } 41 | } 42 | 43 | // EnsureAllIndexes ensures that all the MeiliSearch indexes exist and are 44 | // configured correctly. It should be called at startup. 45 | func (c *Client) EnsureAllIndexes() error { 46 | if err := c.EnsureUserIndex(); err != nil { 47 | return fmt.Errorf("failed to ensure user index: %w", err) 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /web/ui/src/components/ClusterMap/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Actions, PayloadOf } from '@components/UserPopup'; 2 | import type { ClusterViewQuery } from '@graphql.d'; 3 | import { NonNullable } from 'types/utils'; 4 | import { ICampus, ICluster } from '@lib/clustersMap'; 5 | 6 | // ClusterMap.tsx 7 | export type MapLocation = NonNullable< 8 | ClusterViewQuery['locationsByCluster']['edges'][number]['node'] 9 | >; 10 | 11 | type Connection = { 12 | edges: Array<{ node?: Pick | null } | null>; 13 | }; 14 | 15 | type NodeFinderFunc = ( 16 | connection: T, 17 | identifier: string, 18 | ) => NonNullable['node'] | null; 19 | 20 | type NodeIndexFinderFunc = ( 21 | connection: T, 22 | identifier: string, 23 | ) => number | -1; 24 | 25 | // ClusterContainer.tsx 26 | type ClusterContainerChildrenProps = { 27 | locations: ClusterViewQuery['locationsByCluster']; 28 | showPopup: (s: PayloadOf) => void; 29 | hidePopup: () => void; 30 | }; 31 | 32 | export type ClusterContainerProps = { 33 | campus: ICampus; 34 | cluster: ICluster; 35 | children: (props: ClusterContainerChildrenProps) => JSX.Element; 36 | }; 37 | 38 | type ClusterContainerComponent = (props: ClusterContainerProps) => JSX.Element; 39 | 40 | // Represents that state made available via this reducer 41 | type ClusterState = { 42 | highlight: boolean; 43 | hightlightVisibility: (identifier: string) => 'HIGHLIGHT' | 'DIMMED'; 44 | }; 45 | 46 | // This is what our PopupContext will be expecting as its value prop. 47 | type ClusterContextInterface = readonly ClusterState; 48 | -------------------------------------------------------------------------------- /web/ui/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloProvider } from '@apollo/client'; 2 | import { MeProvider } from '@ctx/currentUser'; 3 | import { NotificationProvider, useNotification } from '@ctx/notifications'; 4 | import { useApollo } from '@lib/apollo'; 5 | import { ThemeProvider } from 'next-themes'; 6 | import { AppProps } from 'next/app'; 7 | import Script from 'next/script'; 8 | import { useMemo } from 'react'; 9 | 10 | import '../styles/globals.css'; 11 | 12 | const Interface = ({ Component, pageProps: props = {} }: AppProps) => { 13 | const { initialApolloState, ...pageProps } = props; 14 | 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | const MemorizedComponent = useMemo(() => Component, [pageProps]); 17 | 18 | // Use the layout defined at the page level, if available 19 | const getLayout = MemorizedComponent.getLayout || ((page) => page); 20 | 21 | const { addNotification } = useNotification(); 22 | const apolloClient = useApollo(initialApolloState, { addNotification }); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | {getLayout()} 31 | 32 | 33 | 34 | 35 | 40 | 41 | ); 42 | }; 43 | 44 | export default Interface; 45 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/__tests__/TextInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { TextInput } from '@components/Form'; 2 | import { fireEvent, render, waitFor } from '@testing-library/react'; 3 | 4 | const defaultValue = 'Hello, world!'; 5 | const updatedValue = 'Hello, you!'; 6 | 7 | describe('TextInput', () => { 8 | const onChange = jest.fn(); 9 | 10 | it('renders the input with the given default value', async () => { 11 | render( 12 | , 19 | ); 20 | const inputElement = document.getElementById( 21 | 'test-input', 22 | ) as HTMLInputElement; 23 | 24 | // Check that the input is rendered 25 | await waitFor(() => { 26 | expect(inputElement.value).toBe(defaultValue); 27 | expect(onChange).not.toHaveBeenCalled(); 28 | }); 29 | }); 30 | 31 | it('calls the onChange callback with the new value when the input is changed', async () => { 32 | render( 33 | , 39 | ); 40 | const inputElement = document.getElementById( 41 | 'test-input', 42 | ) as HTMLInputElement; 43 | 44 | // Check that the color input is rendered 45 | expect(inputElement).toBeDefined(); 46 | 47 | // Fill out the form 48 | await waitFor(() => { 49 | fireEvent.change(inputElement, { target: { value: updatedValue } }); 50 | expect(onChange).toHaveBeenCalledWith(updatedValue); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /deploy/stacks/apps/s42/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | // reversedRootDomain is the root domain name, reversed, for use in the 3 | // reverse proxy configuration. For example, if the root domain is 4 | // example.tld, the reversed root domain is tld-example 5 | // If the root domain is pr-23.previews.example.tld, the reversed root 6 | // domain is tld-example-previews 7 | reversedRootDomain = join( 8 | "-", 9 | slice( 10 | reverse(split(".", var.rootDomain)), 11 | 0, 12 | min(3, length(split(".", var.rootDomain))) 13 | ) 14 | ) 15 | 16 | // nodepoolSelector is a selector that matches all nodes in the node pool 17 | // that the application is deployed to 18 | nodepoolSelector = { 19 | storages = { 20 | nodepool = var.namespace == "production" ? "medium" : var.namespace == "staging" ? "small" : "small-shared" 21 | } 22 | services = { 23 | nodepool = var.namespace == "production" ? "small" : var.namespace == "staging" ? "small" : "small-shared" 24 | } 25 | } 26 | 27 | // campusToRefreshEachHourManually is a list of campus IDs that should be 28 | // refreshed each hour manually. This is a workaround for a bug in the 29 | // interconnection between the Intra API and S42. This is a workaround for 30 | // the following bug 31 | campusToRefreshEachHourManually = { 32 | paris = 1 33 | angouleme = 31 34 | helsinki = 13 35 | lausanne = 47 36 | madrid = 22 37 | malaga = 37 38 | mulhouse = 48 39 | sao-paulo = 20 40 | seoul = 29 41 | tokyo = 26 42 | urduliz = 40 43 | vienna = 53 44 | wolfsburg = 44 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/Badgy.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { PropsWithClassName } from 'types/globals'; 3 | 4 | /** 5 | * Badgy is a small badge that can be used to indicate a status or a label 6 | */ 7 | export const Badgy: React.FC> = ({ 8 | text, 9 | className, 10 | }) => ( 11 | 17 | {text} 18 | 19 | ); 20 | 21 | /** 22 | * NewBadgy is a small badge that can be used to indicate a status or a label 23 | * for new features 24 | */ 25 | export const NewBadgy = Badgy.bind(null, { 26 | text: 'New', 27 | className: 'bg-indigo-400 dark:bg-indigo-600 text-white', 28 | }); 29 | 30 | /** 31 | * BetaBadgy is a small badge that can be used to indicate a status or a label 32 | * for beta features 33 | */ 34 | export const BetaBadgy = Badgy.bind(null, { 35 | text: 'Beta', 36 | className: 'bg-yellow-400 dark:bg-yellow-600 text-white', 37 | }); 38 | 39 | /** 40 | * DeprecatedBadgy is a small badge that can be used to indicate a status or a 41 | * label for deprecated features or features that are going to be removed 42 | * soon 43 | */ 44 | export const DeprecatedBadgy = Badgy.bind(null, { 45 | text: 'Deprecated', 46 | className: 'bg-red-400 dark:bg-red-600 text-white', 47 | }); 48 | 49 | /** 50 | * AkaBadgy is a small badge that can be used to indicate a status or a label 51 | * for anything that are "also known as" something else 52 | */ 53 | export const AkaBadgy = Badgy.bind(null, { 54 | text: 'Aka', 55 | className: 'bg-slate-400 dark:bg-slate-700 text-white', 56 | }); 57 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/utils.ts: -------------------------------------------------------------------------------- 1 | import { AccountProvider } from '@graphql.d'; 2 | import { thridPartyData } from './data'; 3 | import { ThridPartySortable } from './types'; 4 | 5 | /** 6 | * Sort the accounts by the order of the thridPartyData object. 7 | * This is used to display all thrid party accounts in the same order. 8 | */ 9 | export const thirdPartySorted = ( 10 | accounts: T[], 11 | duoLogin?: string, 12 | ) => { 13 | let accs: T[] = []; 14 | accounts?.forEach((a) => (a !== null ? a && accs.push(a) : null)); 15 | 16 | if (duoLogin) { 17 | /** 18 | * If the user don't have a duo account in database due to the fact that 19 | * the user didn't login yet to the application, we add it to the user 20 | * object to be able to display it in the user profile. 21 | */ 22 | if (!accs.some((a) => a?.provider === AccountProvider.DUO)) { 23 | accs.push({ 24 | provider: AccountProvider.DUO, 25 | username: duoLogin, 26 | providerAccountId: '', 27 | } as T); 28 | } 29 | 30 | /** 31 | * Push the slack account to the accounts array without required to have 32 | * a slack account in database. This is due to the fact that the slack 33 | * account is not linked and is not required to be linked. The slack 34 | * username is the duo login by design. 35 | */ 36 | accs.push({ 37 | provider: 'SLACK' as AccountProvider, 38 | username: duoLogin, 39 | providerAccountId: '', 40 | } as T); 41 | } 42 | 43 | const sortedKeys = Object.keys(thridPartyData); 44 | return accs.sort( 45 | (a, b) => sortedKeys.indexOf(a.provider) - sortedKeys.indexOf(b.provider), 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /.github/workflows/devcontainers.yaml: -------------------------------------------------------------------------------- 1 | name: Test Devcontainer 2 | 3 | on: 4 | schedule: 5 | - cron: "30 5 * * 1,3" 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .devcontainer/** 11 | pull_request: 12 | paths: 13 | - .devcontainer/** 14 | 15 | jobs: 16 | test: 17 | strategy: 18 | matrix: 19 | # I dont have time to implement test on macOS and Windows for now. 20 | os: [ubuntu-latest] 21 | name: Test Devcontainer on ${{ matrix.os }} 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - name: Checkout project 25 | uses: actions/checkout@v4 26 | 27 | - name: Build and run Dev Container tests 28 | id: devcontainers-tests 29 | uses: devcontainers/ci@v0.3 30 | with: 31 | push: never 32 | noCache: true 33 | runCmd: | 34 | echo "Devcontainer is running... Exiting in 5 seconds." 35 | sleep 5 36 | 37 | - name: Create Issue on Failure 38 | if: failure() && steps.devcontainers-tests.outcome == 'failure' 39 | uses: actions/github-script@v7 40 | with: 41 | script: | 42 | const issue = { 43 | owner: context.repo.owner, 44 | repo: context.repo.repo, 45 | title: "Test Devcontainer failed on ${{ matrix.os }}", 46 | assignees: ["{{ context.repo.owner }}"], 47 | labels: ["aspect/dex 🤖", "type/bug 🔥"], 48 | body: `The Test Devcontainer workflow failed on ${{ matrix.os }}. Please investigate. [Workflow Run](${context.payload.workflow.run_url})`, 49 | }; 50 | // github.rest.issues.create(issue); 51 | -------------------------------------------------------------------------------- /internal/models/schema/notice.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "entgo.io/ent" 8 | "entgo.io/ent/dialect/entsql" 9 | "entgo.io/ent/schema/edge" 10 | "entgo.io/ent/schema/field" 11 | "entgo.io/ent/schema/index" 12 | "github.com/google/uuid" 13 | 14 | "github.com/42atomys/stud42/internal/models/gotype" 15 | ) 16 | 17 | type Notice struct { 18 | ent.Schema 19 | } 20 | 21 | func (Notice) Fields() []ent.Field { 22 | return []ent.Field{ 23 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 24 | Default: "uuid_generate_v4()", 25 | }), 26 | field.String("slug").Unique().NotEmpty().MaxLen(255).Validate(func(s string) error { 27 | if regexp.MustCompile(`^[a-z][a-z0-9-]*$`).MatchString(s) { 28 | return nil 29 | } 30 | return errors.New("must be always akebab-case (lowercase and dash) and not start with a dash") 31 | }), 32 | field.String("message"), 33 | field.String("icon").NotEmpty().MaxLen(255), 34 | field.Enum("color").GoType(gotype.NoticeColor("")).Default(gotype.NoticeColorBlack.String()), 35 | field.Bool("is_active").Default(true), 36 | field.Time("created_at").Immutable().Annotations(entsql.Annotation{ 37 | Default: "CURRENT_TIMESTAMP", 38 | }), 39 | } 40 | } 41 | 42 | func (Notice) Edges() []ent.Edge { 43 | return []ent.Edge{ 44 | edge.From("readers", User.Type). 45 | Ref("readed_notices"). 46 | Through("notices_users", NoticesUser.Type), 47 | } 48 | } 49 | 50 | func (Notice) Indexes() []ent.Index { 51 | return []ent.Index{ 52 | index.Fields("slug").Unique(), 53 | } 54 | } 55 | 56 | func (Notice) Hooks() []ent.Hook { 57 | return []ent.Hook{} 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | on: 3 | workflow_call: 4 | outputs: 5 | image: 6 | description: The image builded and pushed to the registry 7 | value: ${{ jobs.docker_images.outputs.image }} 8 | imageTag: 9 | description: The image tag 10 | value: ${{ jobs.docker_images.outputs.imageTag }} 11 | jobs: 12 | docker_images: 13 | name: "docker images" 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | env: 19 | REGISTRY: ghcr.io 20 | IMAGE_NAME: ${{ github.repository }} 21 | outputs: 22 | image: ${{ steps.meta.outputs.tags }} 23 | imageTag: ${{ steps.meta.outputs.version }} 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | github-token: ${{ github.token }} 41 | 42 | - name: Build and push Docker image 43 | uses: docker/build-push-action@v6 44 | with: 45 | context: . 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | file: build/Dockerfile 50 | build-args: | 51 | APP_VERSION=${{ steps.meta.outputs.version }} 52 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/__tests__/ColorInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { ColorInput } from '@components/Form'; 2 | import { fireEvent, render, waitFor } from '@testing-library/react'; 3 | 4 | const defaultValue = '#ff0000'; 5 | const updatedValue = '#00ff00'; 6 | 7 | describe('ColorInput', () => { 8 | const onChange = jest.fn(); 9 | 10 | it('renders the color input with the given default value', () => { 11 | const { container } = render( 12 | , 18 | ); 19 | const labelElement = container.querySelector( 20 | 'label>span', 21 | ) as HTMLSpanElement; 22 | const colorInputElement = document.getElementById( 23 | 'test-color-input', 24 | ) as HTMLInputElement; 25 | 26 | expect(labelElement).toHaveTextContent('Test Color Input'); 27 | expect(colorInputElement).toHaveAttribute('value', defaultValue); 28 | }); 29 | 30 | it('calls the onChange callback with the selected color when the value changes', async () => { 31 | render( 32 | , 37 | ); 38 | const colorInputElement = document.getElementById( 39 | 'test-color-input', 40 | ) as HTMLInputElement; 41 | 42 | // Check that the color input is rendered 43 | expect(colorInputElement).toBeDefined(); 44 | 45 | // Fill out the form 46 | await waitFor(() => { 47 | fireEvent.change(colorInputElement as Element, { 48 | target: { value: updatedValue }, 49 | }); 50 | expect(onChange).toHaveBeenCalledWith(updatedValue); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /internal/models/schema/location.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/contrib/entgql" 5 | "entgo.io/ent" 6 | "entgo.io/ent/dialect/entsql" 7 | "entgo.io/ent/schema" 8 | "entgo.io/ent/schema/edge" 9 | "entgo.io/ent/schema/field" 10 | "entgo.io/ent/schema/index" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | type Location struct { 15 | ent.Schema 16 | } 17 | 18 | func (Location) Fields() []ent.Field { 19 | return []ent.Field{ 20 | field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{ 21 | Default: "uuid_generate_v4()", 22 | }), 23 | field.UUID("user_id", uuid.UUID{}), 24 | field.UUID("campus_id", uuid.UUID{}), 25 | field.Int("duo_id").Unique().NonNegative(), 26 | field.Time("begin_at"), 27 | field.Time("end_at").Nillable().Optional(), 28 | field.String("identifier").NotEmpty().MaxLen(255), 29 | field.Int("user_duo_id").NonNegative(), 30 | field.String("user_duo_login").NotEmpty().MaxLen(255), 31 | } 32 | } 33 | 34 | func (Location) Edges() []ent.Edge { 35 | return []ent.Edge{ 36 | edge.From("campus", Campus.Type).Required().Field("campus_id").Ref("locations").Unique(), 37 | edge.From("user", User.Type).Required().Field("user_id").Ref("locations").Unique(), 38 | } 39 | } 40 | 41 | func (Location) Indexes() []ent.Index { 42 | return []ent.Index{ 43 | index.Fields("duo_id").Unique(), 44 | index.Fields("user_id").Annotations(entsql.IndexAnnotation{Type: "gin"}), 45 | index.Fields("identifier"), 46 | index.Fields("end_at").StorageKey("locations_actives_idx").Annotations( 47 | entsql.IndexWhere("end_at IS NULL"), 48 | ), 49 | } 50 | } 51 | 52 | func (Location) Annotations() []schema.Annotation { 53 | return []schema.Annotation{ 54 | entgql.RelayConnection(), 55 | entgql.QueryField(), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /deploy/modules/istio/variables.tf: -------------------------------------------------------------------------------- 1 | variable "gateways" { 2 | description = "The list of gateways to create" 3 | type = map(object({ 4 | namespace = string 5 | ingressSelectorName = string 6 | serverHttpsRedirect = bool 7 | hosts = list(string) 8 | tlsMode = string 9 | tlsCredentialName = string 10 | 11 | extraServers = optional(list(object({ 12 | port = object({ 13 | number = number 14 | name = string 15 | protocol = string 16 | }) 17 | hosts = list(string) 18 | tls = optional(object({ 19 | mode = optional(string) 20 | credentialName = optional(string) 21 | httpsRedirect = optional(bool, false) 22 | }), {}) 23 | })), []) 24 | })) 25 | default = {} 26 | } 27 | 28 | variable "virtual_services" { 29 | description = "The list of virtual services to create" 30 | type = map(object({ 31 | namespace = string 32 | hosts = list(string) 33 | gateways = list(string) 34 | http = optional(list(object({ 35 | name = string 36 | 37 | match = optional(list(object({ 38 | uri = optional(object({ 39 | exact = optional(string) 40 | prefix = optional(string) 41 | })) 42 | method = optional(object({ 43 | exact = optional(string) 44 | })) 45 | }))) 46 | 47 | rewrite = optional(object({ 48 | uri = string 49 | })) 50 | 51 | route = list(object({ 52 | destination = object({ 53 | host = string 54 | port = optional(object({ 55 | number = number 56 | })) 57 | }) 58 | })) 59 | })), []) 60 | })) 61 | default = {} 62 | } 63 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/ColorInput.tsx: -------------------------------------------------------------------------------- 1 | import { ColorDisplay } from '@components/ColorDisplay'; 2 | import classNames from 'classnames'; 3 | import type { DataType } from 'csstype'; 4 | import { useState } from 'react'; 5 | import { PropsWithClassName } from 'types/globals'; 6 | import { InputProps } from './types'; 7 | 8 | export const ColorInput: React.FC< 9 | PropsWithClassName> 10 | > = ({ 11 | onChange, 12 | className, 13 | defaultValue, 14 | label: labelName, 15 | name, 16 | ...inputProps 17 | }) => { 18 | const [value, setValue] = useState( 19 | defaultValue || 'transparent', 20 | ); 21 | const onChangeCallback = (newValue: DataType.Color) => { 22 | setValue(newValue); 23 | onChange(newValue); 24 | }; 25 | 26 | // set an identifier for the input name as url-safe slug 27 | const inputId = name?.replace(/[^a-z0-9]/gi, '-').toLowerCase(); 28 | 29 | return ( 30 |
36 | 40 | onChangeCallback(e.target.value)} 43 | value={value} 44 | id={inputId} 45 | className="invisible w-0 h-0 absolute focus:outline-none border-0 p-0 bg-transparent text-slate-800 dark:text-slate-200 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" 46 | {...inputProps} 47 | /> 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /web/ui/src/lib/clustersMap/campuses.generated.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT THIS FILE MANUALLY - IT IS GENERATED FROM THE CONTENT OF THE CAMPUS FOLDER 2 | // RUN `yarn generate:campus` TO REGENERATE IT 3 | 4 | import { ICampus } from './types'; 5 | import { CampusIdentifier } from './types.generated'; 6 | 7 | import { Angouleme } from './campus/angouleme'; 8 | import { Helsinki } from './campus/helsinki'; 9 | import { Lausanne } from './campus/lausanne'; 10 | import { LeHavre } from './campus/leHavre'; 11 | import { Madrid } from './campus/madrid'; 12 | import { Malaga } from './campus/malaga'; 13 | import { Mulhouse } from './campus/mulhouse'; 14 | import { Paris } from './campus/paris'; 15 | import { SaoPaulo } from './campus/saoPaulo'; 16 | import { Seoul } from './campus/seoul'; 17 | import { Tokyo } from './campus/tokyo'; 18 | import { Urduliz } from './campus/urduliz'; 19 | import { Vienna } from './campus/vienna'; 20 | import { Wolfsburg } from './campus/wolfsburg'; 21 | 22 | /** 23 | * Campuses represents the list of campuses present in the application. 24 | * Particulary, used in the cluster map. 25 | * 26 | * It is a const, so it can be accessed from anywhere in the application. 27 | * You can add a new campus by define the campus in the `campus` folder 28 | * (see `campus/paris.ts` for an example) and run `yarn generate:campus` 29 | */ 30 | export const Campuses: Record = { 31 | angouleme: new Angouleme(), 32 | helsinki: new Helsinki(), 33 | lausanne: new Lausanne(), 34 | leHavre: new LeHavre(), 35 | madrid: new Madrid(), 36 | malaga: new Malaga(), 37 | mulhouse: new Mulhouse(), 38 | paris: new Paris(), 39 | saoPaulo: new SaoPaulo(), 40 | seoul: new Seoul(), 41 | tokyo: new Tokyo(), 42 | urduliz: new Urduliz(), 43 | vienna: new Vienna(), 44 | wolfsburg: new Wolfsburg(), 45 | }; 46 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/Switch.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useState } from 'react'; 3 | import { SwitchProps } from './types'; 4 | 5 | export const Switch: React.FC< 6 | React.PropsWithChildren> 7 | > = ({ children, defaultValue, onChange, color, ...props }) => { 8 | const [isChecked, setIsChecked] = useState(defaultValue || false); 9 | const toggle = () => { 10 | setIsChecked(!isChecked); 11 | onChange?.(!isChecked); 12 | }; 13 | const switchColorStyle = { backgroundColor: color }; 14 | return ( 15 | <> 16 | 43 | {children} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /tools/sealedSecret.py: -------------------------------------------------------------------------------- 1 | # Generate a new sealed secret for the given namespace and object data 2 | # Usage: python3 create.py --name --namespace --data = [= ...] 3 | 4 | import argparse 5 | import base64 6 | import json 7 | import subprocess 8 | 9 | parser = argparse.ArgumentParser(description='Convert a secret to a sealed secret.') 10 | 11 | parser.add_argument('--name', metavar='N', nargs=1, type=str, help='The name of the secret to create') 12 | parser.add_argument('--namespace', metavar='n', nargs=1, type=str, help='The namespace to create the secret in') 13 | parser.add_argument('--data', metavar='d', nargs='+', type=str, help='The data to encrypt') 14 | args = parser.parse_args() 15 | 16 | # Convert the data to a dictionary of key/value pairs and base64 encode 17 | # the values 18 | data = {} 19 | for item in args.data: 20 | key, value = item.split('=', 1) 21 | value = base64.b64encode(value.encode('utf-8')).decode('utf-8') 22 | data[key] = value 23 | 24 | # Generate the sealed secret 25 | secret = { 26 | "apiVersion": "v1", 27 | "kind": "Secret", 28 | "metadata": { 29 | "name": args.name[0], 30 | "namespace": args.namespace[0], 31 | "annotations": { 32 | "sealedsecrets.bitnami.com/cluster-wide": "false", 33 | "sealedsecrets.bitnami.com/namespace-wide": "true", 34 | }, 35 | }, 36 | "data": data, 37 | } 38 | 39 | # Encode the secret as a JSON string and encrypt the secret 40 | encrypted_secret = subprocess.check_output( 41 | ["kubeseal", "--format", "json"], 42 | input=json.dumps(secret), 43 | encoding="utf-8", 44 | ) 45 | 46 | # Print the encrypted secret 47 | print( 48 | json.dumps( 49 | json.loads(encrypted_secret)['spec']['encryptedData'], 50 | indent=2, 51 | ).replace('": "', '" = "') 52 | ) -------------------------------------------------------------------------------- /web/ui/src/pages/clusters/index.tsx: -------------------------------------------------------------------------------- 1 | import { MeWithFlagsDocument, MeWithFlagsQuery } from '@graphql.d'; 2 | import { queryAuthenticatedSSR } from '@lib/apollo'; 3 | import Campuses from '@lib/clustersMap'; 4 | import { clusterURL } from '@lib/searchEngine'; 5 | import type { GetServerSideProps, NextPage } from 'next'; 6 | 7 | type PageProps = {}; 8 | 9 | const Home: NextPage = () => <>; 10 | 11 | export const getServerSideProps: GetServerSideProps = async ({ 12 | query, 13 | req, 14 | }) => { 15 | const { campus, identifier } = query; 16 | 17 | if (campus && identifier) { 18 | const url = clusterURL(campus.toString(), identifier.toString()); 19 | 20 | if (url) { 21 | return { 22 | redirect: { 23 | destination: url, 24 | permanent: false, 25 | }, 26 | props: {}, 27 | }; 28 | } 29 | } 30 | 31 | const { data: { me } = {} } = await queryAuthenticatedSSR( 32 | req, 33 | { query: MeWithFlagsDocument }, 34 | ); 35 | const myCampusNameFromAPI = me?.currentCampus?.name?.toSafeLink() || ''; 36 | 37 | const myCampus = Object.values(Campuses).find( 38 | (v) => v.name().toSafeLink() === myCampusNameFromAPI, 39 | ); 40 | 41 | if (!!myCampus) { 42 | const clusterKey = myCampus.clusters()[0].identifier(); 43 | return { 44 | redirect: { 45 | destination: `/clusters/${myCampusNameFromAPI}/${clusterKey}`, 46 | permanent: false, 47 | }, 48 | props: {}, 49 | }; 50 | } 51 | 52 | // When no campus is found, redirect to the campus of Paris. 53 | return { 54 | redirect: { 55 | destination: `/clusters/paris/${Campuses.paris 56 | .clusters()[0] 57 | .identifier()}`, 58 | permanent: false, 59 | }, 60 | props: {}, 61 | }; 62 | }; 63 | 64 | export default Home; 65 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/FlagBadge.tsx: -------------------------------------------------------------------------------- 1 | import { ConditionalWrapper } from '@components/ConditionalWrapper'; 2 | import { Tooltip } from '@components/Tooltip'; 3 | import { UserFlag } from '@graphql.d'; 4 | import classNames from 'classnames'; 5 | import Link from 'next/link'; 6 | import { flagData } from './data'; 7 | 8 | /** 9 | * The FlagBadge component is a UI component that displays an icon representing 10 | * a particular flag along with a tooltip containing more information about that 11 | * flag. 12 | * 13 | * It takes in a flag props, which specifies the type of flag to display. 14 | * The component uses a data object: `flagData`, to determine how to display it. 15 | * If the flagData object also contains a link property for the flag, 16 | * the icon will be wrapped in a Link component that opens the link in a 17 | * new tab when clicked. 18 | */ 19 | export const FlagBadge: React.FC<{ flag: UserFlag }> = ({ flag }) => { 20 | const data = flagData[flag]; 21 | 22 | return ( 23 | 33 | ( 36 | 42 | {children} 43 | 44 | )} 45 | > 46 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /web/ui/src/__mocks__/framerMotionMock.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const actual = jest.requireActual('framer-motion'); 4 | 5 | // https://github.com/framer/motion/blob/main/src/render/dom/motion.ts 6 | const custom = ( 7 | Component: string | React.ComponentType>, 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | _customMotionComponentConfig = {}, 10 | ) => { 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore 13 | const c = React.forwardRef((props, ref) => { 14 | const regularProps = Object.fromEntries( 15 | // do not pass framer props to DOM element 16 | Object.entries(props).filter(([key]) => !actual.isValidMotionProp(key)), 17 | ); 18 | return typeof Component === 'string' ? ( 19 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 20 | // @ts-ignore 21 |
22 | ) : ( 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-ignore 25 | 26 | ); 27 | }); 28 | c.displayName = `motion.mock(${ 29 | typeof Component === 'string' 30 | ? Component 31 | : Component.displayName || Component.name 32 | })`; 33 | return c; 34 | }; 35 | 36 | const componentCache = new Map(); 37 | const motion = new Proxy(custom, { 38 | get: (_target, key: string) => { 39 | if (!componentCache.has(key)) { 40 | componentCache.set(key, custom(key)); 41 | } 42 | 43 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 44 | return componentCache.get(key)!; 45 | }, 46 | }); 47 | 48 | const AnimatePresence = ({ children }: { children: typeof React.Children }) => ( 49 | <>{children} 50 | ); 51 | 52 | export { actual, AnimatePresence, motion }; 53 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/__tests__/SelectInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { SelectInput } from '@components/Form'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | 4 | describe('SelectInput', () => { 5 | const objects = ['Object 1', 'Object 2', 'Object 3']; 6 | const selectedValue = objects[0]; 7 | const onChange = jest.fn(); 8 | 9 | it('renders the select input with the given objects', () => { 10 | const { getByText } = render( 11 | , 16 | ); 17 | const selectedObjectElement = getByText(selectedValue); 18 | expect(selectedObjectElement).toBeInTheDocument(); 19 | }); 20 | 21 | it('opens the options list when clicked', () => { 22 | const { getByText, getByRole } = render( 23 | , 28 | ); 29 | const selectButtonElement = getByRole('button'); 30 | fireEvent.click(selectButtonElement); 31 | const optionsListElement = getByText(objects[1]) 32 | .parentElement as HTMLElement; 33 | expect(optionsListElement).toBeInTheDocument(); 34 | }); 35 | 36 | it('calls the onChange callback with the selected object when clicked', () => { 37 | const { getByText, getByRole } = render( 38 | , 43 | ); 44 | const selectButtonElement = getByRole('button'); 45 | fireEvent.click(selectButtonElement); 46 | const optionsListElement = getByText(objects[1]) 47 | .parentElement as HTMLElement; 48 | fireEvent.click(optionsListElement); 49 | expect(onChange).toHaveBeenCalledWith(objects[1]); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /web/ui/src/components/Badge/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Account, AccountProvider, UserFlag } from '@graphql.d'; 2 | import { PropsWithClassName } from 'types/globals'; 3 | import { ClickableLink } from 'types/utils'; 4 | 5 | /** 6 | * Represents the properties used to style a badge component. 7 | */ 8 | export type BadgeProps = { 9 | color: BadgeColor; 10 | text?: string; 11 | }; 12 | 13 | export type BadgeColor = 14 | | 'purple' 15 | | 'blue' 16 | | 'green' 17 | | 'yellow' 18 | | 'red' 19 | | 'orange' 20 | | 'gray' 21 | | 'white' 22 | | 'black' 23 | | 'transparent'; 24 | 25 | /** 26 | * Represents the properties used to render the ThridPartyBadge component. 27 | */ 28 | export interface ThridPartySortable 29 | extends Pick {} 30 | 31 | /** 32 | * A type used to describe an object with name, description, and link properties. 33 | * It extends the ClickableLink type, which means it also can have a link property. 34 | * This is used to describe the data used to render the flag badges and the third 35 | * party badges. 36 | */ 37 | type ObjectMapData = PropsWithClassName< 38 | ClickableLink & { 39 | name: string; 40 | description?: string; 41 | } 42 | >; 43 | 44 | /** 45 | * Represents an object that maps each UserFlag to an ObjectMapData 46 | * object. It is used to provide data for the FlagBadge component. 47 | */ 48 | export type FlagDataMap = { 49 | [key in UserFlag]: ObjectMapData; 50 | }; 51 | 52 | /** 53 | * Represents an object that maps each AccountProvider 54 | * (and a custom SLACK provider) o an ObjectMapData object. 55 | * It is used to provide data for the ThridPartyBadge component. 56 | */ 57 | export type ThridPartyAccountDataMap = { 58 | [key in AccountProvider | 'SLACK']: ObjectMapData<{ 59 | username: string; 60 | providerAccountId: string; 61 | }>; 62 | }; 63 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useRef, useState } from 'react'; 3 | import { PropsWithClassName } from 'types/globals'; 4 | import { TextInputProps } from './types'; 5 | 6 | export const TextInput: React.FC< 7 | PropsWithClassName> 8 | > = ({ 9 | onChange, 10 | className, 11 | defaultValue, 12 | label: labelName, 13 | name, 14 | debounce = 0, 15 | ...inputProps 16 | }) => { 17 | const [value, setValue] = useState(defaultValue || ''); 18 | const timeout = useRef(); 19 | const onChangeCallback = (newValue: string) => { 20 | setValue(newValue); 21 | 22 | if (debounce === 0) return onChange(newValue); 23 | if (timeout.current) clearTimeout(timeout.current); 24 | timeout.current = setTimeout(() => { 25 | onChange(newValue); 26 | }, debounce); 27 | }; 28 | 29 | // set an identifier for the input name as url-safe slug 30 | const inputId = name.replace(/[^a-z0-9]/gi, '-').toLowerCase(); 31 | 32 | return ( 33 |
40 | {labelName && ( 41 | 44 | )} 45 | onChangeCallback(e.target.value)} 47 | value={value} 48 | id={inputId} 49 | className="block w-full focus:outline-none border-0 p-0 bg-transparent text-slate-800 dark:text-slate-200 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" 50 | {...inputProps} 51 | /> 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /web/ui/src/components/Form/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useState } from 'react'; 3 | import { PropsWithClassName } from 'types/globals'; 4 | import { FileInputProps } from './types'; 5 | export const FileInput: React.FC< 6 | PropsWithClassName< 7 | Omit, 'label'> 8 | > 9 | > = ({ onChange, className, defaultValue, name, ...inputProps }) => { 10 | const [, setValue] = useState(defaultValue || ''); 11 | const onChangeCallback = (newValue: EventTarget & HTMLInputElement) => { 12 | setValue(newValue); 13 | onChange(newValue); 14 | }; 15 | 16 | // set an identifier for the input name as url-safe slug 17 | const inputId = name.replace(/[^a-z0-9]/gi, '-').toLowerCase(); 18 | 19 | return ( 20 |
26 | 38 | onChangeCallback(e.target)} 41 | id={inputId} 42 | className="hidden" 43 | {...inputProps} 44 | /> 45 |
46 | ); 47 | }; 48 | --------------------------------------------------------------------------------