diff --git a/backend/.scribe/.filehashes b/backend/.scribe/.filehashes new file mode 100644 index 00000000..9cf1036f --- /dev/null +++ b/backend/.scribe/.filehashes @@ -0,0 +1,4 @@ +# GENERATED. YOU SHOULDN'T MODIFY OR DELETE THIS FILE. +# Scribe uses this file to know when you change something manually in your docs. +.scribe/intro.md=63d14186b9cbbb0a80ee87cd913db091 +.scribe/auth.md=5c5a140c89034600ae349aede2a22ec8 \ No newline at end of file diff --git a/backend/.scribe/auth.md b/backend/.scribe/auth.md new file mode 100644 index 00000000..bf1aa6a7 --- /dev/null +++ b/backend/.scribe/auth.md @@ -0,0 +1,7 @@ +# Authenticating requests + +To authenticate requests, include an **`Authorization`** header with the value **`"Bearer Bearer {token}"`**. + +All authenticated endpoints are marked with a `requires authentication` badge in the documentation below. + +Get tokens from `POST /api/auth/login`, send access token as `Bearer {token}`, and renew with `POST /api/auth/refresh` before access token expiry. diff --git a/backend/.scribe/endpoints.cache/00.yaml b/backend/.scribe/endpoints.cache/00.yaml new file mode 100644 index 00000000..97014a68 --- /dev/null +++ b/backend/.scribe/endpoints.cache/00.yaml @@ -0,0 +1,226 @@ +## Autogenerated by Scribe. DO NOT MODIFY. + +name: Authentication +description: |- + + Endpoints for JWT authentication and session lifecycle. +endpoints: + - + custom: [] + httpMethods: + - POST + uri: api/auth/login + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Login and get tokens' + description: 'Authenticate with email and password to receive an access token and refresh token.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + email: + custom: [] + name: email + description: 'User email address.' + required: true + example: user@example.com + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + password: + custom: [] + name: password + description: 'User password.' + required: true + example: secret123 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + email: user@example.com + password: secret123 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "abc123def456", + "token_type": "bearer", + "expires_in": 3600, + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Alice Johnson", + "email": "user@example.com", + "role": "manager" + } + } + headers: [] + description: '' + - + custom: [] + status: 401 + content: '{"message":"Invalid credentials"}' + headers: [] + description: '' + - + custom: [] + status: 403 + content: '{"message":"Account is inactive"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"errors":{"email":["The email field is required."],"password":["The password field is required."]}}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/auth/refresh + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Refresh access token' + description: 'Exchange a valid refresh token for a new access token and refresh token pair.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + refresh_token: + custom: [] + name: refresh_token + description: 'Refresh token returned by login.' + required: true + example: abc123def456 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + refresh_token: abc123def456 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "newtoken123", + "token_type": "bearer", + "expires_in": 3600 + } + headers: [] + description: '' + - + custom: [] + status: 401 + content: '{"message":"Invalid or expired refresh token"}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/auth/logout + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Logout current session' + description: 'Invalidate a refresh token and end the active authenticated session.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + refresh_token: + custom: [] + name: refresh_token + description: 'Optional refresh token to invalidate immediately.' + required: false + example: abc123def456 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + refresh_token: abc123def456 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"message":"Logged out successfully"}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null diff --git a/backend/.scribe/endpoints/00.yaml b/backend/.scribe/endpoints/00.yaml new file mode 100644 index 00000000..f4794ee7 --- /dev/null +++ b/backend/.scribe/endpoints/00.yaml @@ -0,0 +1,224 @@ +name: Authentication +description: |- + + Endpoints for JWT authentication and session lifecycle. +endpoints: + - + custom: [] + httpMethods: + - POST + uri: api/auth/login + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Login and get tokens' + description: 'Authenticate with email and password to receive an access token and refresh token.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + email: + custom: [] + name: email + description: 'User email address.' + required: true + example: user@example.com + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + password: + custom: [] + name: password + description: 'User password.' + required: true + example: secret123 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + email: user@example.com + password: secret123 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "abc123def456", + "token_type": "bearer", + "expires_in": 3600, + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Alice Johnson", + "email": "user@example.com", + "role": "manager" + } + } + headers: [] + description: '' + - + custom: [] + status: 401 + content: '{"message":"Invalid credentials"}' + headers: [] + description: '' + - + custom: [] + status: 403 + content: '{"message":"Account is inactive"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"errors":{"email":["The email field is required."],"password":["The password field is required."]}}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/auth/refresh + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Refresh access token' + description: 'Exchange a valid refresh token for a new access token and refresh token pair.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + refresh_token: + custom: [] + name: refresh_token + description: 'Refresh token returned by login.' + required: true + example: abc123def456 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + refresh_token: abc123def456 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "newtoken123", + "token_type": "bearer", + "expires_in": 3600 + } + headers: [] + description: '' + - + custom: [] + status: 401 + content: '{"message":"Invalid or expired refresh token"}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/auth/logout + metadata: + custom: [] + groupName: Authentication + groupDescription: |- + + Endpoints for JWT authentication and session lifecycle. + subgroup: '' + subgroupDescription: '' + title: 'Logout current session' + description: 'Invalidate a refresh token and end the active authenticated session.' + authenticated: true + deprecated: false + headers: + Authorization: 'Bearer Bearer {token}' + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + refresh_token: + custom: [] + name: refresh_token + description: 'Optional refresh token to invalidate immediately.' + required: false + example: abc123def456 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + refresh_token: abc123def456 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"message":"Logged out successfully"}' + headers: [] + description: '' + responseFields: [] + auth: + - headers + - Authorization + - 'Bearer Bearer {token}' + controller: null + method: null + route: null diff --git a/backend/.scribe/endpoints/custom.0.yaml b/backend/.scribe/endpoints/custom.0.yaml new file mode 100644 index 00000000..4b023521 --- /dev/null +++ b/backend/.scribe/endpoints/custom.0.yaml @@ -0,0 +1,53 @@ +# To include an endpoint that isn't a part of your Laravel app (or belongs to a vendor package), +# you can define it in a custom.*.yaml file, like this one. +# Each custom file should contain an array of endpoints. Here's an example: +# See https://scribe.knuckles.wtf/laravel/documenting/custom-endpoints#extra-sorting-groups-in-custom-endpoint-files for more options + +#- httpMethods: +# - POST +# uri: api/doSomething/{param} +# metadata: +# groupName: The group the endpoint belongs to. Can be a new group or an existing group. +# groupDescription: A description for the group. You don't need to set this for every endpoint; once is enough. +# subgroup: You can add a subgroup, too. +# title: Do something +# description: 'This endpoint allows you to do something.' +# authenticated: false +# headers: +# Content-Type: application/json +# Accept: application/json +# urlParameters: +# param: +# name: param +# description: A URL param for no reason. +# required: true +# example: 2 +# type: integer +# queryParameters: +# speed: +# name: speed +# description: How fast the thing should be done. Can be `slow` or `fast`. +# required: false +# example: fast +# type: string +# bodyParameters: +# something: +# name: something +# description: The things we should do. +# required: true +# example: +# - string 1 +# - string 2 +# type: 'string[]' +# responses: +# - status: 200 +# description: 'When the thing was done smoothly.' +# content: # Your response content can be an object, an array, a string or empty. +# { +# "hey": "ho ho ho" +# } +# responseFields: +# hey: +# name: hey +# description: Who knows? +# type: string # This is optional diff --git a/backend/.scribe/intro.md b/backend/.scribe/intro.md new file mode 100644 index 00000000..bc297e8a --- /dev/null +++ b/backend/.scribe/intro.md @@ -0,0 +1,12 @@ +# Introduction + +Resource planning and capacity management API + + + + Authenticate by sending `Authorization: Bearer {access_token}` on protected endpoints. + + Access tokens are valid for 60 minutes. Use `/api/auth/refresh` with your refresh token to obtain a new access token and refresh token pair. + diff --git a/backend/composer.json b/backend/composer.json index 0a9b8501..456661eb 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -10,6 +10,7 @@ "knuckleswtf/scribe": "^5.7", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "parsedown/parsedown": "1.7.4", "predis/predis": "^2.0", "tymon/jwt-auth": "^2.0" }, diff --git a/backend/composer.lock b/backend/composer.lock index 92ffa71a..b623cc3a 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2782b93777d132b40f4d1f45c0483384", + "content-hash": "eb1f270f832bd2bd086e4cccb3a4945d", "packages": [ { "name": "brick/math", @@ -3043,24 +3043,24 @@ }, { "name": "parsedown/parsedown", - "version": "1.8.0", + "version": "1.7.4", "source": { "type": "git", "url": "https://github.com/parsedown/parsedown.git", - "reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7" + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/parsedown/parsedown/zipball/96baaad00f71ba04d76e45b4620f54d3beabd6f7", - "reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7", + "url": "https://api.github.com/repos/parsedown/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=7.1" + "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^7.5|^8.5|^9.6" + "phpunit/phpunit": "^4.8.35" }, "type": "library", "autoload": { @@ -3087,9 +3087,9 @@ ], "support": { "issues": "https://github.com/parsedown/parsedown/issues", - "source": "https://github.com/parsedown/parsedown/tree/1.8.0" + "source": "https://github.com/parsedown/parsedown/tree/1.7.4" }, - "time": "2026-02-16T11:41:01+00:00" + "time": "2019-12-30T22:54:17+00:00" }, { "name": "phpoption/phpoption", diff --git a/backend/config/scribe.php b/backend/config/scribe.php index 487f86f6..3fb82ec7 100644 --- a/backend/config/scribe.php +++ b/backend/config/scribe.php @@ -4,7 +4,6 @@ use Knuckles\Scribe\Config\AuthIn; use Knuckles\Scribe\Config\Defaults; use Knuckles\Scribe\Extracting\Strategies; -use function Knuckles\Scribe\Config\configureStrategy; use function Knuckles\Scribe\Config\removeStrategies; // Only the most common configs are shown. See the https://scribe.knuckles.wtf/laravel/reference/config for all. @@ -45,7 +44,7 @@ return [ // Exclude these routes even if they matched the rules above. 'exclude' => [ - // 'GET /health', 'admin.*' + 'api/user', ], ], ], @@ -231,15 +230,9 @@ return [ 'bodyParameters' => [ ...Defaults::BODY_PARAMETERS_STRATEGIES, ], - 'responses' => configureStrategy( + 'responses' => removeStrategies( Defaults::RESPONSES_STRATEGIES, - Strategies\Responses\ResponseCalls::withSettings( - only: ['GET *'], - // Recommended: disable debug mode in response calls to avoid error stack traces in responses - config: [ - 'app.debug' => false, - ] - ) + [Strategies\Responses\ResponseCalls::class], ), 'responseFields' => [ ...Defaults::RESPONSE_FIELDS_STRATEGIES, diff --git a/backend/public/vendor/scribe/css/theme-default.print.css b/backend/public/vendor/scribe/css/theme-default.print.css new file mode 100644 index 00000000..18ab760e --- /dev/null +++ b/backend/public/vendor/scribe/css/theme-default.print.css @@ -0,0 +1,393 @@ +/* Copied from https://github.com/slatedocs/slate/blob/c4b4c0b8f83e891ca9fab6bbe9a1a88d5fe41292/stylesheets/print.css and unminified */ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100% +} + +body { + margin: 0 +} + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block +} + +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline +} + +audio:not([controls]) { + display: none; + height: 0 +} + +[hidden], +template { + display: none +} + +a { + background-color: transparent +} + +a:active, +a:hover { + outline: 0 +} + +abbr[title] { + border-bottom: 1px dotted +} + +b, +strong { + font-weight: bold +} + +dfn { + font-style: italic +} + +h1 { + font-size: 2em; + margin: 0.67em 0 +} + +mark { + background: #ff0; + color: #000 +} + +small { + font-size: 80% +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline +} + +sup { + top: -0.5em +} + +sub { + bottom: -0.25em +} + +img { + border: 0 +} + +svg:not(:root) { + overflow: hidden +} + +figure { + margin: 1em 40px +} + +hr { + box-sizing: content-box; + height: 0 +} + +pre { + overflow: auto +} + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em +} + +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0 +} + +button { + overflow: visible +} + +button, +select { + text-transform: none +} + +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer +} + +button[disabled], +html input[disabled] { + cursor: default +} + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0 +} + +input { + line-height: normal +} + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; + padding: 0 +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto +} + +input[type="search"] { + -webkit-appearance: textfield; + box-sizing: content-box +} + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none +} + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em +} + +legend { + border: 0; + padding: 0 +} + +textarea { + overflow: auto +} + +optgroup { + font-weight: bold +} + +table { + border-collapse: collapse; + border-spacing: 0 +} + +td, +th { + padding: 0 +} + +.content h1, +.content h2, +.content h3, +.content h4, +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 14px +} + +.content h1, +.content h2, +.content h3, +.content h4 { + font-weight: bold +} + +.content pre, +.content code { + font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace, serif; + font-size: 12px; + line-height: 1.5 +} + +.content pre, +.content code { + word-break: break-all; + -webkit-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto +} + +@font-face { + font-family: 'slate'; + src: url(../fonts/slate.eot?-syv14m); + src: url(../fonts/slate.eot?#iefix-syv14m) format("embedded-opentype"), url(../fonts/slate.woff2?-syv14m) format("woff2"), url(../fonts/slate.woff?-syv14m) format("woff"), url(../fonts/slate.ttf?-syv14m) format("truetype"), url(../fonts/slate.svg?-syv14m#slate) format("svg"); + font-weight: normal; + font-style: normal +} + +.content aside.warning:before, +.content aside.notice:before, +.content aside.success:before { + font-family: 'slate'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1 +} + +.content aside.warning:before { + content: "\e600" +} + +.content aside.notice:before { + content: "\e602" +} + +.content aside.success:before { + content: "\e606" +} + +.tocify, +.toc-footer, +.lang-selector, +.search, +#nav-button { + display: none +} + +.tocify-wrapper>img { + margin: 0 auto; + display: block +} + +.content { + font-size: 12px +} + +.content pre, +.content code { + border: 1px solid #999; + border-radius: 5px; + font-size: 0.8em +} + +.content pre code { + border: 0 +} + +.content pre { + padding: 1.3em +} + +.content code { + padding: 0.2em +} + +.content table { + border: 1px solid #999 +} + +.content table tr { + border-bottom: 1px solid #999 +} + +.content table td, +.content table th { + padding: 0.7em +} + +.content p { + line-height: 1.5 +} + +.content a { + text-decoration: none; + color: #000 +} + +.content h1 { + font-size: 2.5em; + padding-top: 0.5em; + padding-bottom: 0.5em; + margin-top: 1em; + margin-bottom: 21px; + border: 2px solid #ccc; + border-width: 2px 0; + text-align: center +} + +.content h2 { + font-size: 1.8em; + margin-top: 2em; + border-top: 2px solid #ccc; + padding-top: 0.8em +} + +.content h1+h2, +.content h1+div+h2 { + border-top: none; + padding-top: 0; + margin-top: 0 +} + +.content h3, +.content h4 { + font-size: 0.8em; + margin-top: 1.5em; + margin-bottom: 0.8em; + text-transform: uppercase +} + +.content h5, +.content h6 { + text-transform: uppercase +} + +.content aside { + padding: 1em; + border: 1px solid #ccc; + border-radius: 5px; + margin-top: 1.5em; + margin-bottom: 1.5em; + line-height: 1.6 +} + +.content aside:before { + vertical-align: middle; + padding-right: 0.5em; + font-size: 14px +} diff --git a/backend/public/vendor/scribe/css/theme-default.style.css b/backend/public/vendor/scribe/css/theme-default.style.css new file mode 100644 index 00000000..08f2e93a --- /dev/null +++ b/backend/public/vendor/scribe/css/theme-default.style.css @@ -0,0 +1,1094 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +html { + font-family: 'Open Sans', sans-serif; + font-size: 1.2em; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100% +} + +body { + margin: 0 +} + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section { + display: block +} + +summary { + cursor: pointer; +} + +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline +} + +audio:not([controls]) { + display: none; + height: 0 +} + +[hidden], +template { + display: none +} + +a { + background-color: transparent +} + +a:active, +a:hover { + outline: 0 +} + +abbr[title] { + border-bottom: 1px dotted +} + +b, +strong { + font-weight: 700 +} + +dfn { + font-style: italic +} + +h1 { + font-size: 2em; + margin: .67em 0 +} + +mark { + background: #ff0; + color: #000 +} + +small { + font-size: 80% +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline +} + +sup { + top: -.5em +} + +sub { + bottom: -.25em +} + +img { + border: 0 +} + +svg:not(:root) { + overflow: hidden +} + +figure { + margin: 1em 40px +} + +hr { + box-sizing: content-box; + height: 0 +} + +pre { + overflow: auto +} + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em +} + +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0 +} + +button { + overflow: visible +} + +button, +select { + text-transform: none +} + +button, +html input[type=button], +input[type=reset], +input[type=submit] { + -webkit-appearance: button; + cursor: pointer +} + +button[disabled], +html input[disabled] { + cursor: default +} + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0 +} + +input { + line-height: normal +} + +input[type=checkbox], +input[type=radio] { + box-sizing: border-box; + padding: 0 +} + +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + height: auto +} + +input[type=search] { + -webkit-appearance: textfield; + box-sizing: content-box +} + +input[type=search]::-webkit-search-cancel-button, +input[type=search]::-webkit-search-decoration { + -webkit-appearance: none +} + +fieldset { + border: 1px solid silver; + margin: 0 2px; + padding: .35em .625em .75em +} + +legend { + border: 0; + padding: 0 +} + +textarea { + overflow: auto +} + +optgroup { + font-weight: 700 +} + +table { + border-collapse: collapse; + border-spacing: 0 +} + +td, +th { + padding: 0 +} + +body, +html { + font-family: 'Open Sans', Helvetica Neue, Helvetica, Arial, Microsoft Yahei, 微软雅黑, STXihei, 华文细黑, sans-serif; + font-size: 16px; +} + +.content h1, +.content h2, +.content h3, +.content h4, +.content h5, +.content h6 { + font-family: 'Open Sans', Helvetica Neue, Helvetica, Arial, Microsoft Yahei, 微软雅黑, STXihei, 华文细黑, sans-serif; +} + +.content h1, +.content h2, +.content h3, +.content h4, +.content h5, +.content h6 { + font-weight: 700 +} + +.content code, +.content pre { + font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif; + font-size: 14px; + line-height: 1.5 +} + +.content code { + word-break: break-all; + word-break: break-word; + -webkit-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto +} + +.content aside.notice:before, +.content aside.success:before, +.content aside.warning:before, +.tocify-wrapper>.search:before { + font-family: 'Open Sans', sans-serif; + speak: none; + font-style: normal; + font-variant: normal; + text-transform: none; + line-height: 1 +} + +.content aside.warning:before { + content: "✋" +} + +.content aside.notice:before { + content: "ℹ" +} + +.content aside.success:before { + content: "✅" +} + +.tocify-wrapper>.search:before { + content: "🔎" +} + +.highlight .c, +.highlight .c1, +.highlight .cm, +.highlight .cs { + color: #909090 +} + +.highlight, +.highlight .w { + background-color: #292929 +} + +.hljs { + display: block; + overflow-x: auto; + padding: .5em; + background: #23241f +} + +.hljs, +.hljs-subst, +.hljs-tag { + color: #f8f8f2 +} + +.hljs-emphasis, +.hljs-strong { + color: #a8a8a2 +} + +.hljs-bullet, +.hljs-link, +.hljs-literal, +.hljs-number, +.hljs-quote, +.hljs-regexp { + color: #ae81ff +} + +.hljs-code, +.hljs-section, +.hljs-selector-class, +.hljs-title { + color: #a6e22e +} + +.hljs-strong { + font-weight: 700 +} + +.hljs-emphasis { + font-style: italic +} + +.hljs-attr, +.hljs-keyword, +.hljs-name, +.hljs-selector-tag { + color: #f92672 +} + +.hljs-attribute, +.hljs-symbol { + color: #66d9ef +} + +.hljs-class .hljs-title, +.hljs-params { + color: #f8f8f2 +} + +.hljs-addition, +.hljs-built_in, +.hljs-builtin-name, +.hljs-selector-attr, +.hljs-selector-id, +.hljs-selector-pseudo, +.hljs-string, +.hljs-template-variable, +.hljs-type, +.hljs-variable { + color: #e6db74 +} + +.hljs-comment, +.hljs-deletion, +.hljs-meta { + color: #75715e +} + +body, +html { + color: #333; + padding: 0; + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: whitesmoke; + height: 100%; + -webkit-text-size-adjust: none +} + +#toc>ul>li>a>span { + float: right; + background-color: #2484ff; + border-radius: 40px; + width: 20px +} + +.tocify-wrapper { + transition: left .3s ease-in-out; + overflow-y: auto; + overflow-x: hidden; + position: fixed; + z-index: 30; + top: 0; + left: 0; + bottom: 0; + width: 230px; + background-color: #393939; + font-size: 13px; + font-weight: 700 +} + +.tocify-wrapper .lang-selector { + display: none +} + +.tocify-wrapper .lang-selector a { + padding-top: .5em; + padding-bottom: .5em +} + +.tocify-wrapper>img { + display: block +} + +.tocify-wrapper>.search { + position: relative +} + +.tocify-wrapper>.search input { + background: #393939; + border-width: 0 0 1px; + border-color: #666; + padding: 6px 0 6px 20px; + box-sizing: border-box; + margin: 10px 15px; + width: 200px; + outline: none; + color: #fff; + border-radius: 0 +} + +.tocify-wrapper>.search:before { + position: absolute; + top: 17px; + left: 15px; + color: #fff +} + +.tocify-wrapper img+.tocify { + margin-top: 20px +} + +.tocify-wrapper .search-results { + margin-top: 0; + box-sizing: border-box; + height: 0; + overflow-y: auto; + overflow-x: hidden; + transition-property: height, margin; + transition-duration: .18s; + transition-timing-function: ease-in-out; + background: linear-gradient(180deg, rgba(0, 0, 0, .2), transparent 8px), linear-gradient(0deg, rgba(0, 0, 0, .2), transparent 8px), linear-gradient(180deg, #000, transparent 1.5px), linear-gradient(0deg, #939393, hsla(0, 0%, 58%, 0) 1.5px), #262626 +} + +.tocify-wrapper .search-results.visible { + height: 30%; + margin-bottom: 1em +} + +.tocify-wrapper .search-results li { + margin: 1em 15px; + line-height: 1 +} + +.tocify-wrapper a { + color: #fff; + text-decoration: none +} + +.tocify-wrapper .search-results a:hover { + text-decoration: underline +} + +.tocify-wrapper .toc-footer li, +.tocify-wrapper .tocify-item>a { + padding: 0 15px; + display: block; + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis +} +.tocify-wrapper .tocify-item.level-3>a { + padding: 0 25px; +} + +.tocify-wrapper li, +.tocify-wrapper ul { + list-style: none; + margin: 0; + padding: 0; + line-height: 28px +} + +.tocify-wrapper li { + color: #fff; + transition-property: background; + transition-timing-function: linear; + transition-duration: .23s +} + +.tocify-wrapper .tocify-focus { + box-shadow: 0 1px 0 #000; + background-color: #2467af; + color: #fff; + font-weight: bold; +} + +.tocify-wrapper .tocify-subheader { + display: none; + background-color: #262626; + font-weight: 500; + background: linear-gradient(180deg, rgba(0, 0, 0, .2), transparent 8px), linear-gradient(0deg, rgba(0, 0, 0, .2), transparent 8px), linear-gradient(180deg, #000, transparent 1.5px), linear-gradient(0deg, #939393, hsla(0, 0%, 58%, 0) 1.5px), #262626 +} + +.tocify-wrapper .jets-searching .tocify-subheader, +.tocify-wrapper .tocify-subheader.visible { + display: block; +} + +.tocify-wrapper .tocify-subheader .tocify-item>a { + padding-left: 25px; + font-size: 12px +} + +.tocify-wrapper .tocify-subheader .tocify-item.level-3>a { + padding-left: 35px; +} + +.tocify-wrapper .tocify-subheader>li:last-child { + box-shadow: none +} + +.tocify-wrapper .toc-footer { + padding: 1em 0; + margin-top: 1em; + border-top: 1px dashed #666 +} + +.tocify-wrapper .toc-footer a, +.tocify-wrapper .toc-footer li { + color: #fff; + text-decoration: none +} + +.tocify-wrapper .toc-footer a:hover { + text-decoration: underline +} + +.tocify-wrapper .toc-footer li { + font-size: .8em; + line-height: 1.7; + text-decoration: none +} + +#nav-button { + padding: 0 1.5em 5em 0; + display: none; + position: fixed; + top: 0; + left: 0; + z-index: 100; + color: #000; + text-decoration: none; + font-weight: 700; + opacity: .7; + line-height: 16px; + transition: left .3s ease-in-out +} + +#nav-button span { + display: block; + padding: 6px; + background-color: rgba(234, 242, 246, .7); + -webkit-transform-origin: 0 0; + transform-origin: 0 0; + -webkit-transform: rotate(-90deg) translate(-100%); + transform: rotate(-90deg) translate(-100%); + border-radius: 0 0 0 5px +} + +#nav-button img { + height: 16px; + vertical-align: bottom +} + +#nav-button:hover { + opacity: 1 +} + +#nav-button.open { + left: 230px +} + +.page-wrapper { + margin-left: 230px; + position: relative; + z-index: 10; + background-color: #eaf2f6; + min-height: 100%; + padding-bottom: 1px +} + +.page-wrapper .dark-box { + width: 50%; + background-color: #393939; + position: absolute; + right: 0; + top: 0; + bottom: 0 +} + +.page-wrapper .lang-selector { + position: fixed; + z-index: 50; + border-bottom: 5px solid #393939 +} + +.lang-selector { + background-color: #222; + width: 100%; + font-weight: 700 +} + +.lang-selector button { + display: block; + float: left; + color: #fff; + text-decoration: none; + padding: 0 10px; + line-height: 30px; + outline: 0; + background: transparent; + border: none; +} + +.lang-selector button:active, +.lang-selector button:hover, +.lang-selector button:focus { + background-color: #111; + color: #fff +} + +.lang-selector button.active { + background-color: #393939; + color: #fff +} + +.lang-selector:after { + content: ''; + clear: both; + display: block +} + +.content { + position: relative; + z-index: 30 +} + +.content:after { + content: ''; + display: block; + clear: both +} + +.content>aside, +.content>details, +.content>dl, +.content>h1, +.content>h2, +.content>h3, +.content>h4, +.content>h5, +.content>h6, +.content>ol, +.content>p, +.content>table, +.content>ul, +.content>div, +.content>form>aside, +.content>form>details, +.content>form>h1, +.content>form>h2, +.content>form>h3, +.content>form>h4, +.content>form>h5, +.content>form>h6, +.content>form>p, +.content>form>table, +.content>form>ul, +.content>form>div { + margin-right: 50%; + padding: 0 28px; + box-sizing: border-box; + display: block; + text-shadow: 0 1px 0 #fff +} + +.content>ol, +.content>ul { + padding-left: 43px +} + +.content>div, +.content>h1, +.content>h2 { + clear: both +} + +.content h1 { + font-size: 30px; + padding-top: .5em; + padding-bottom: .5em; + border-bottom: 1px solid #ccc; + margin-bottom: 21px; + margin-top: 2em; + border-top: 1px solid #ddd; + background-image: linear-gradient(180deg, #fff, #f9f9f9) +} + +.content div:first-child+h1, +.content h1:first-child { + border-top-width: 0; + margin-top: 0 +} + +.content h2 { + font-size: 20px; + margin-top: 4em; + margin-bottom: 0; + border-top: 1px solid #ccc; + padding-top: 1.2em; + padding-bottom: 1.2em; + background-image: linear-gradient(180deg, hsla(0, 0%, 100%, .4), hsla(0, 0%, 100%, 0)) +} + +.content h1+div+h2, +.content h1+h2 { + margin-top: -21px; + border-top: none +} + +.content h3, +.content h4, +.content h5, +.content h6 { + font-size: 15px; + margin-top: 2.5em; + margin-bottom: .8em +} + +.content h4, +.content h5, +.content h6 { + font-size: 10px +} + +.content hr { + margin: 2em 0; + border-top: 2px solid #393939; + border-bottom: 2px solid #eaf2f6 +} + +.content table { + margin-bottom: 1em; + overflow: auto +} + +.content table td, +.content table th { + text-align: left; + vertical-align: top; + line-height: 1.6 +} + +.content table th { + padding: 5px 10px; + border-bottom: 1px solid #ccc; + vertical-align: bottom +} + +.content table td { + padding: 10px +} + +.content table tr:last-child { + border-bottom: 1px solid #ccc +} + +.content table tr:nth-child(odd)>td { + background-color: #ebf3f6 +} + +.content table tr:nth-child(even)>td { + background-color: #ebf2f6 +} + +.content dt { + font-weight: 700 +} + +.content dd { + margin-left: 15px +} + +.content dd, +.content dt, +.content li, +.content p { + line-height: 1.6; + margin-top: 0 +} + +.content img { + max-width: 100% +} + +.content code { + padding: 3px; + border-radius: 3px +} + +.content pre>code { + background-color: transparent; + padding: 0 +} + +.content aside { + padding-top: 1em; + padding-bottom: 1em; + margin-top: 1.5em; + margin-bottom: 1.5em; + background: #292929; + line-height: 1.6; + color: #c8c8c8; + text-shadow: none; +} + +.content aside.info { + background: #8fbcd4; + text-shadow: 0 1px 0 #a0c6da; + color: initial; +} + +.content aside.warning { + background-color: #c97a7e; + text-shadow: 0 1px 0 #d18e91; + color: initial; +} + +.content aside.success { + background-color: #6ac174; + text-shadow: 0 1px 0 #80ca89; + color: initial; +} + +.content aside:before { + vertical-align: middle; + padding-right: .5em; + font-size: 14px +} + +.content .search-highlight { + padding: 2px; + margin: -2px; + border-radius: 4px; + border: 1px solid #f7e633; + text-shadow: 1px 1px 0 #666; + background: linear-gradient(to top left, #f7e633, #f1d32f) +} + +.content blockquote, +.content pre { + background-color: #292929; + color: #fff; + padding: 1.5em 28px; + margin: 0; + width: 50%; + float: right; + clear: right; + box-sizing: border-box; + text-shadow: 0 1px 2px rgba(0, 0, 0, .4) +} + +.content blockquote pre.sf-dump, +.content pre pre.sf-dump { + width: 100%; +} + +.content .annotation { + background-color: #292929; + color: #fff; + padding: 0 28px; + margin: 0; + width: 50%; + float: right; + clear: right; + box-sizing: border-box; + text-shadow: 0 1px 2px rgba(0, 0, 0, .4) +} + +.content .annotation pre { + padding: 0 0; + width: 100%; + float: none; +} + +.content blockquote>p, +.content pre>p { + margin: 0 +} + +.content blockquote a, +.content pre a { + color: #fff; + text-decoration: none; + border-bottom: 1px dashed #ccc +} + +.content blockquote>p { + background-color: #1c1c1c; + border-radius: 5px; + padding: 13px; + color: #ccc; + border-top: 1px solid #000; + border-bottom: 1px solid #404040 +} + +@media (max-width:930px) { + .tocify-wrapper { + left: -230px + } + .tocify-wrapper.open { + left: 0 + } + .page-wrapper { + margin-left: 0 + } + #nav-button { + display: block + } + .tocify-wrapper .tocify-item>a { + padding-top: .3em; + padding-bottom: .3em + } +} + +@media (max-width:700px) { + .dark-box { + display: none + } + .tocify-wrapper .lang-selector { + display: block + } + .page-wrapper .lang-selector { + display: none + } + .content>aside, + .content>details, + .content>dl, + .content>h1, + .content>h2, + .content>h3, + .content>h4, + .content>h5, + .content>h6, + .content>ol, + .content>p, + .content>table, + .content>ul, + .content>div, + .content>form>aside, + .content>form>details, + .content>form>h1, + .content>form>h2, + .content>form>h3, + .content>form>h4, + .content>form>h5, + .content>form>h6, + .content>form>p, + .content>form>table, + .content>form>ul, + .content>form>div { + margin-right: 0; + } + .content blockquote, + .content pre { + float: none; + width: auto + } + .content .annotation { + float: none; + width: auto + } +} + +.badge { + padding: 1px 9px 2px; + white-space: nowrap; + -webkit-border-radius: 9px; + -moz-border-radius: 9px; + border-radius: 9px; + color: #ffffff; + text-shadow: none !important; + font-weight: bold; +} + +.badge.badge-darkred { + background-color: darkred; +} + +.badge.badge-red { + background-color: red; +} + +.badge.badge-blue { + background-color: blue; +} + +.badge.badge-darkblue { + background-color: darkblue; +} + +.badge.badge-green { + background-color: green; +} + +.badge.badge-darkgoldenrod { + background-color: darkgoldenrod; +} + +.badge.badge-darkgreen { + background-color: darkgreen; +} + +.badge.badge-purple { + background-color: purple; +} + +.badge.badge-black { + background-color: black; +} + +.badge.badge-grey { + background-color: grey; +} + +.fancy-heading-panel { + background-color: lightgrey; + border-radius: 5px; + padding-left: 5px !important; + padding-top: 5px !important; + padding-bottom: 5px !important; + margin-left: 25px; + margin-right: 10px; + width: 47%; +} + +@media screen and (max-width: 700px) { + .fancy-heading-panel { + width: 95%; + } + +} + +button { + border: none; +} + +* { + /* Foreground, Background */ + scrollbar-color: #3c4c67 transparent; +} +*::-webkit-scrollbar { /* Background */ + width: 10px; + height: 10px; + background: transparent; +} + +*::-webkit-scrollbar-thumb { /* Foreground */ + background: #626161; +} diff --git a/backend/public/vendor/scribe/images/navbar.png b/backend/public/vendor/scribe/images/navbar.png new file mode 100644 index 00000000..df38e90d Binary files /dev/null and b/backend/public/vendor/scribe/images/navbar.png differ diff --git a/backend/public/vendor/scribe/js/theme-default-5.7.0.js b/backend/public/vendor/scribe/js/theme-default-5.7.0.js new file mode 100644 index 00000000..31c84514 --- /dev/null +++ b/backend/public/vendor/scribe/js/theme-default-5.7.0.js @@ -0,0 +1,149 @@ +document.addEventListener('DOMContentLoaded', function() { + const updateHash = function (id) { + window.location.hash = `#${id}`; + }; + + const navButton = document.getElementById('nav-button'); + const menuWrapper = document.querySelector('.tocify-wrapper'); + function toggleSidebar(event) { + event.preventDefault(); + if (menuWrapper) { + menuWrapper.classList.toggle('open'); + navButton.classList.toggle('open'); + } + } + function closeSidebar() { + if (menuWrapper) { + menuWrapper.classList.remove('open'); + navButton.classList.remove('open'); + } + } + navButton.addEventListener('click', toggleSidebar); + + window.hljs.highlightAll(); + + const wrapper = document.getElementById('toc'); + // https://jets.js.org/ + window.jets = new window.Jets({ + // *OR - Selects elements whose values contains at least one part of search substring + searchSelector: '*OR', + searchTag: '#input-search', + contentTag: '#toc li', + didSearch: function(term) { + wrapper.classList.toggle('jets-searching', String(term).length > 0) + }, + // map these accent keys to plain values + diacriticsMap: { + a: 'ÀÁÂÃÄÅàáâãäåĀāąĄ', + c: 'ÇçćĆčČ', + d: 'đĐďĎ', + e: 'ÈÉÊËèéêëěĚĒēęĘ', + i: 'ÌÍÎÏìíîïĪī', + l: 'łŁ', + n: 'ÑñňŇńŃ', + o: 'ÒÓÔÕÕÖØòóôõöøŌō', + r: 'řŘ', + s: 'ŠšśŚ', + t: 'ťŤ', + u: 'ÙÚÛÜùúûüůŮŪū', + y: 'ŸÿýÝ', + z: 'ŽžżŻźŹ' + } + }); + + function hashChange() { + const currentItems = document.querySelectorAll('.tocify-subheader.visible, .tocify-item.tocify-focus'); + Array.from(currentItems).forEach((elem) => { + elem.classList.remove('visible', 'tocify-focus'); + }); + + const currentTag = document.querySelector(`a[href="${window.location.hash}"]`); + if (currentTag) { + const parent = currentTag.closest('.tocify-subheader'); + if (parent) { + parent.classList.add('visible'); + } + + const siblings = currentTag.closest('.tocify-header'); + if (siblings) { + Array.from(siblings.querySelectorAll('.tocify-subheader')).forEach((elem) => { + elem.classList.add('visible'); + }); + } + + currentTag.parentElement.classList.add('tocify-focus'); + + // wait for dom changes to be done + setTimeout(() => { + currentTag.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + // only close the sidebar on level-2 events + if (currentTag.parentElement.classList.contains('level-2')) { + closeSidebar(); + } + }, 1500); + } + } + + let languages = JSON.parse(document.body.getAttribute('data-languages')); + // Support a key => value object where the key is the name, or an array of strings where the value is the name + if (!Array.isArray(languages)) { + languages = Object.values(languages); + } + // if there is no language use the first one + const currentLanguage = window.localStorage.getItem('language') || languages[0]; + const languageStyle = document.getElementById('language-style'); + const langSelector = document.querySelectorAll('.lang-selector button.lang-button'); + + function setActiveLanguage(newLanguage) { + window.localStorage.setItem('language', newLanguage); + if (!languageStyle) { + return; + } + + const newStyle = languages.map((language) => { + return language === newLanguage + // the current one should be visible + ? `body .content .${language}-example pre { display: block; }` + // the inactive one should be hidden + : `body .content .${language}-example pre { display: none; }`; + }).join(`\n`); + + Array.from(langSelector).forEach((elem) => { + elem.classList.toggle('active', elem.getAttribute('data-language-name') === newLanguage); + }); + + const activeHash = window.location.hash.slice(1); + + languageStyle.innerHTML = newStyle; + + setTimeout(() => { + updateHash(activeHash); + }, 200); + } + + setActiveLanguage(currentLanguage); + + Array.from(langSelector).forEach((elem) => { + elem.addEventListener('click', () => { + const newLanguage = elem.getAttribute('data-language-name'); + setActiveLanguage(newLanguage); + }); + }); + + window.addEventListener('hashchange', hashChange, false); + + const divs = document.querySelectorAll('.content h1[id], .content h2[id]'); + + document.addEventListener('scroll', () => { + divs.forEach(item => { + const rect = item.getBoundingClientRect(); + if (rect.top > 0 && rect.top < 150) { + const location = window.location.toString().split('#')[0]; + history.replaceState(null, null, location + '#' + item.id); + hashChange(); + } + }); + }); + + hashChange(); +}); diff --git a/backend/public/vendor/scribe/js/tryitout-5.7.0.js b/backend/public/vendor/scribe/js/tryitout-5.7.0.js new file mode 100644 index 00000000..2a1d2b8a --- /dev/null +++ b/backend/public/vendor/scribe/js/tryitout-5.7.0.js @@ -0,0 +1,289 @@ +window.abortControllers = {}; + +function cacheAuthValue() { + // Whenever the auth header is set for one endpoint, cache it for the others + window.lastAuthValue = ''; + let authInputs = document.querySelectorAll(`.auth-value`) + authInputs.forEach(el => { + el.addEventListener('input', (event) => { + window.lastAuthValue = event.target.value; + authInputs.forEach(otherInput => { + if (otherInput === el) return; + // Don't block the main thread + setTimeout(() => { + otherInput.value = window.lastAuthValue; + }, 0); + }); + }); + }); +} + +window.addEventListener('DOMContentLoaded', cacheAuthValue); + +function getCookie(name) { + if (!document.cookie) { + return null; + } + + const cookies = document.cookie.split(';') + .map(c => c.trim()) + .filter(c => c.startsWith(name + '=')); + + if (cookies.length === 0) { + return null; + } + + return decodeURIComponent(cookies[0].split('=')[1]); +} + +function tryItOut(endpointId) { + document.querySelector(`#btn-tryout-${endpointId}`).hidden = true; + document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = false; + const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`).hidden = false; + executeBtn.disabled = false; + + // Show all input fields + document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`) + .forEach(el => el.style.display = 'block'); + + if (document.querySelector(`#form-${endpointId}`).dataset.authed === "1") { + const authElement = document.querySelector(`#auth-${endpointId}`); + authElement && (authElement.hidden = false); + } + // Expand all nested fields + document.querySelectorAll(`#form-${endpointId} details`) + .forEach(el => el.open = true); +} + +function cancelTryOut(endpointId) { + if (window.abortControllers[endpointId]) { + window.abortControllers[endpointId].abort(); + delete window.abortControllers[endpointId]; + } + + document.querySelector(`#btn-tryout-${endpointId}`).hidden = false; + const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`); + executeBtn.hidden = true; + executeBtn.textContent = executeBtn.dataset.initialText; + document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = true; + // Hide inputs + document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`) + .forEach(el => el.style.display = 'none'); + document.querySelectorAll(`#form-${endpointId} details`) + .forEach(el => el.open = false); + const authElement = document.querySelector(`#auth-${endpointId}`); + authElement && (authElement.hidden = true); + + document.querySelector('#execution-results-' + endpointId).hidden = true; + document.querySelector('#execution-error-' + endpointId).hidden = true; + + // Revert to sample code blocks + document.querySelector('#example-requests-' + endpointId).hidden = false; + document.querySelector('#example-responses-' + endpointId).hidden = false; +} + +function makeAPICall(method, path, body = {}, query = {}, headers = {}, endpointId = null) { + console.log({endpointId, path, body, query, headers}); + + if (!(body instanceof FormData) && typeof body !== "string") { + body = JSON.stringify(body) + } + + const url = new URL(window.tryItOutBaseUrl + '/' + path.replace(/^\//, '')); + + // We need this function because if you try to set an array or object directly to a URLSearchParams object, + // you'll get [object Object] or the array.toString() + function addItemToSearchParamsObject(key, value, searchParams) { + if (Array.isArray(value)) { + value.forEach((v, i) => { + // Append {filters: [first, second]} as filters[0]=first&filters[1]second + addItemToSearchParamsObject(key + '[' + i + ']', v, searchParams); + }) + } else if (typeof value === 'object' && value !== null) { + Object.keys(value).forEach((i) => { + // Append {filters: {name: first}} as filters[name]=first + addItemToSearchParamsObject(key + '[' + i + ']', value[i], searchParams); + }); + } else { + searchParams.append(key, value); + } + } + + Object.keys(query) + .forEach(key => addItemToSearchParamsObject(key, query[key], url.searchParams)); + + window.abortControllers[endpointId] = new AbortController(); + + return fetch(url, { + method, + headers, + body: method === 'GET' ? undefined : body, + signal: window.abortControllers[endpointId].signal, + referrer: window.tryItOutBaseUrl, + mode: 'cors', + credentials: 'same-origin', + }) + .then(response => Promise.all([response.status, response.statusText, response.text(), response.headers])); +} + +function hideCodeSamples(endpointId) { + document.querySelector('#example-requests-' + endpointId).hidden = true; + document.querySelector('#example-responses-' + endpointId).hidden = true; +} + +function handleResponse(endpointId, response, status, headers) { + hideCodeSamples(endpointId); + + // Hide error views + document.querySelector('#execution-error-' + endpointId).hidden = true; + + const responseContentEl = document.querySelector('#execution-response-content-' + endpointId); + + // Check if the response contains Laravel's dd() default dump output + const isLaravelDump = response.includes('Sfdump'); + + // If it's a Laravel dd() dump, use innerHTML to render it safely + if (isLaravelDump) { + responseContentEl.innerHTML = response === '' ? responseContentEl.dataset.emptyResponseText : response; + } else { + // Otherwise, stick to textContent for regular responses + responseContentEl.textContent = response === '' ? responseContentEl.dataset.emptyResponseText : response; + } + + // Prettify it if it's JSON + let isJson = false; + try { + const jsonParsed = JSON.parse(response); + if (jsonParsed !== null) { + isJson = true; + response = JSON.stringify(jsonParsed, null, 4); + responseContentEl.textContent = response; + } + } catch (e) { + + } + + isJson && window.hljs.highlightElement(responseContentEl); + const statusEl = document.querySelector('#execution-response-status-' + endpointId); + statusEl.textContent = ` (${status})`; + document.querySelector('#execution-results-' + endpointId).hidden = false; + statusEl.scrollIntoView({behavior: "smooth", block: "center"}); +} + +function handleError(endpointId, err) { + hideCodeSamples(endpointId); + // Hide response views + document.querySelector('#execution-results-' + endpointId).hidden = true; + + // Show error views + let errorMessage = err.message || err; + const $errorMessageEl = document.querySelector('#execution-error-message-' + endpointId); + $errorMessageEl.textContent = errorMessage + $errorMessageEl.textContent; + const errorEl = document.querySelector('#execution-error-' + endpointId); + errorEl.hidden = false; + errorEl.scrollIntoView({behavior: "smooth", block: "center"}); + +} + +async function executeTryOut(endpointId, form) { + const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`); + executeBtn.textContent = executeBtn.dataset.loadingText; + executeBtn.disabled = true; + executeBtn.scrollIntoView({behavior: "smooth", block: "center"}); + + let body; + let setter; + if (form.dataset.hasfiles === "1") { + body = new FormData(); + setter = (name, value) => body.append(name, value); + } else if (form.dataset.isarraybody === "1") { + body = []; + setter = (name, value) => _.set(body, name, value); + } else { + body = {}; + setter = (name, value) => _.set(body, name, value); + } + const bodyParameters = form.querySelectorAll('input[data-component=body]'); + bodyParameters.forEach(el => { + let value = el.value; + + if (el.type === 'number' && typeof value === 'string') { + value = parseFloat(value); + } + + if (el.type === 'file' && el.files[0]) { + setter(el.name, el.files[0]); + return; + } + + if (el.type !== 'radio') { + if (value === "" && el.required === false) { + // Don't include empty optional values in the request + return; + } + setter(el.name, value); + return; + } + + if (el.checked) { + value = (value === 'false') ? false : true; + setter(el.name, value); + } + }); + + const query = {}; + const queryParameters = form.querySelectorAll('input[data-component=query]'); + queryParameters.forEach(el => { + if (el.type !== 'radio' || (el.type === 'radio' && el.checked)) { + if (el.value === '') { + // Don't include empty values in the request + return; + } + + _.set(query, el.name, el.value); + } + }); + + let path = form.dataset.path; + const urlParameters = form.querySelectorAll('input[data-component=url]'); + urlParameters.forEach(el => (path = path.replace(new RegExp(`\\{${el.name}\\??}`), el.value))); + + const headers = Object.fromEntries(Array.from(form.querySelectorAll('input[data-component=header]')) + .map(el => [el.name, el.value])); + + // When using FormData, the browser sets the correct content-type + boundary + let method = form.dataset.method; + if (body instanceof FormData) { + delete headers['Content-Type']; + + // When using FormData with PUT or PATCH, use method spoofing so PHP can access the post body + if (['PUT', 'PATCH'].includes(form.dataset.method)) { + method = 'POST'; + setter('_method', form.dataset.method); + } + } + + let preflightPromise = Promise.resolve(); + if (window.useCsrf && window.csrfUrl) { + preflightPromise = makeAPICall('GET', window.csrfUrl).then(() => { + headers['X-XSRF-TOKEN'] = getCookie('XSRF-TOKEN'); + }); + } + + return preflightPromise.then(() => makeAPICall(method, path, body, query, headers, endpointId)) + .then(([responseStatus, statusText, responseContent, responseHeaders]) => { + handleResponse(endpointId, responseContent, responseStatus, responseHeaders) + }) + .catch(err => { + if (err.name === "AbortError") { + console.log("Request cancelled"); + return; + } + console.log("Error while making request: ", err); + handleError(endpointId, err); + }) + .finally(() => { + executeBtn.disabled = false; + executeBtn.textContent = executeBtn.dataset.initialText; + }); +} diff --git a/backend/resources/views/scribe/index.blade.php b/backend/resources/views/scribe/index.blade.php new file mode 100644 index 00000000..5373812d --- /dev/null +++ b/backend/resources/views/scribe/index.blade.php @@ -0,0 +1,668 @@ + + + + + + + Headroom API + + + + + + + + + + + + + + + + + + + + + + + + + + + MENU + navbar-image + + +
+ +
+ + +
+ + + +
+ + + +
+ + + + +
+ +
+
+
+

Introduction

+

Resource planning and capacity management API

+ +
Authenticate by sending `Authorization: Bearer {access_token}` on protected endpoints.
+
+Access tokens are valid for 60 minutes. Use `/api/auth/refresh` with your refresh token to obtain a new access token and refresh token pair.
+ +

Authenticating requests

+

To authenticate requests, include an Authorization header with the value "Bearer Bearer {token}".

+

All authenticated endpoints are marked with a requires authentication badge in the documentation below.

+

Get tokens from POST /api/auth/login, send access token as Bearer {token}, and renew with POST /api/auth/refresh before access token expiry.

+ +

Authentication

+ +

Endpoints for JWT authentication and session lifecycle.

+ +

Login and get tokens

+ +

+requires authentication +

+ +

Authenticate with email and password to receive an access token and refresh token.

+ + +
Example request:
+ + +
+
curl --request POST \
+    "http://localhost/api/api/auth/login" \
+    --header "Authorization: Bearer Bearer {token}" \
+    --header "Content-Type: application/json" \
+    --header "Accept: application/json" \
+    --data "{
+    \"email\": \"user@example.com\",
+    \"password\": \"secret123\"
+}"
+
+ + +
+
const url = new URL(
+    "http://localhost/api/api/auth/login"
+);
+
+const headers = {
+    "Authorization": "Bearer Bearer {token}",
+    "Content-Type": "application/json",
+    "Accept": "application/json",
+};
+
+let body = {
+    "email": "user@example.com",
+    "password": "secret123"
+};
+
+fetch(url, {
+    method: "POST",
+    headers,
+    body: JSON.stringify(body),
+}).then(response => response.json());
+ +
+ + +
+

Example response (200):

+
+
+
+{
+    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
+    "refresh_token": "abc123def456",
+    "token_type": "bearer",
+    "expires_in": 3600,
+    "user": {
+        "id": "550e8400-e29b-41d4-a716-446655440000",
+        "name": "Alice Johnson",
+        "email": "user@example.com",
+        "role": "manager"
+    }
+}
+ 
+
+

Example response (401):

+
+
+
+{
+    "message": "Invalid credentials"
+}
+ 
+
+

Example response (403):

+
+
+
+{
+    "message": "Account is inactive"
+}
+ 
+
+

Example response (422):

+
+
+
+{
+    "errors": {
+        "email": [
+            "The email field is required."
+        ],
+        "password": [
+            "The password field is required."
+        ]
+    }
+}
+ 
+
+ + +
+

+ Request    + +    + +

+

+ POST + api/auth/login +

+

Headers

+
+ Authorization   +  +   +   + +
+

Example: Bearer Bearer {token}

+
+
+ Content-Type   +  +   +   + +
+

Example: application/json

+
+
+ Accept   +  +   +   + +
+

Example: application/json

+
+

Body Parameters

+
+ email   +string  +   +   + +
+

User email address. Example: user@example.com

+
+
+ password   +string  +   +   + +
+

User password. Example: secret123

+
+
+ +

Refresh access token

+ +

+requires authentication +

+ +

Exchange a valid refresh token for a new access token and refresh token pair.

+ + +
Example request:
+ + +
+
curl --request POST \
+    "http://localhost/api/api/auth/refresh" \
+    --header "Authorization: Bearer Bearer {token}" \
+    --header "Content-Type: application/json" \
+    --header "Accept: application/json" \
+    --data "{
+    \"refresh_token\": \"abc123def456\"
+}"
+
+ + +
+
const url = new URL(
+    "http://localhost/api/api/auth/refresh"
+);
+
+const headers = {
+    "Authorization": "Bearer Bearer {token}",
+    "Content-Type": "application/json",
+    "Accept": "application/json",
+};
+
+let body = {
+    "refresh_token": "abc123def456"
+};
+
+fetch(url, {
+    method: "POST",
+    headers,
+    body: JSON.stringify(body),
+}).then(response => response.json());
+ +
+ + +
+

Example response (200):

+
+
+
+{
+    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
+    "refresh_token": "newtoken123",
+    "token_type": "bearer",
+    "expires_in": 3600
+}
+ 
+
+

Example response (401):

+
+
+
+{
+    "message": "Invalid or expired refresh token"
+}
+ 
+
+ + +
+

+ Request    + +    + +

+

+ POST + api/auth/refresh +

+

Headers

+
+ Authorization   +  +   +   + +
+

Example: Bearer Bearer {token}

+
+
+ Content-Type   +  +   +   + +
+

Example: application/json

+
+
+ Accept   +  +   +   + +
+

Example: application/json

+
+

Body Parameters

+
+ refresh_token   +string  +   +   + +
+

Refresh token returned by login. Example: abc123def456

+
+
+ +

Logout current session

+ +

+requires authentication +

+ +

Invalidate a refresh token and end the active authenticated session.

+ + +
Example request:
+ + +
+
curl --request POST \
+    "http://localhost/api/api/auth/logout" \
+    --header "Authorization: Bearer Bearer {token}" \
+    --header "Content-Type: application/json" \
+    --header "Accept: application/json" \
+    --data "{
+    \"refresh_token\": \"abc123def456\"
+}"
+
+ + +
+
const url = new URL(
+    "http://localhost/api/api/auth/logout"
+);
+
+const headers = {
+    "Authorization": "Bearer Bearer {token}",
+    "Content-Type": "application/json",
+    "Accept": "application/json",
+};
+
+let body = {
+    "refresh_token": "abc123def456"
+};
+
+fetch(url, {
+    method: "POST",
+    headers,
+    body: JSON.stringify(body),
+}).then(response => response.json());
+ +
+ + +
+

Example response (200):

+
+
+
+{
+    "message": "Logged out successfully"
+}
+ 
+
+ + +
+

+ Request    + +    + +

+

+ POST + api/auth/logout +

+

Headers

+
+ Authorization   +  +   +   + +
+

Example: Bearer Bearer {token}

+
+
+ Content-Type   +  +   +   + +
+

Example: application/json

+
+
+ Accept   +  +   +   + +
+

Example: application/json

+
+

Body Parameters

+
+ refresh_token   +string  +optional   +   + +
+

Optional refresh token to invalidate immediately. Example: abc123def456

+
+
+ + + + +
+
+
+ + +
+
+
+ + diff --git a/openspec/changes/p00-api-documentation/design.md b/openspec/changes/archive/2026-02-18-p00-api-documentation/design.md similarity index 71% rename from openspec/changes/p00-api-documentation/design.md rename to openspec/changes/archive/2026-02-18-p00-api-documentation/design.md index 8c12b252..4bb738c0 100644 --- a/openspec/changes/p00-api-documentation/design.md +++ b/openspec/changes/archive/2026-02-18-p00-api-documentation/design.md @@ -93,14 +93,9 @@ public function index(Request $request): JsonResponse ### Group Organization | Group | Controller | Endpoints | |----------------|-----------------------|-----------| -| Authentication | AuthController | 5 | -| Team Members | TeamMemberController | 5 | -| Projects | ProjectController | 8 | -| Allocations | AllocationController | 6 | -| Actuals | ActualController | 5 | -| Capacity | CapacityController | 6 | -| Reports | ReportController | 5 | -| Master Data | MasterDataController | 9 | +| Authentication | AuthController | 3 | + +Additional groups are intentionally deferred until corresponding controllers are added to this repository. ### Authentication Documentation Include a dedicated section explaining: @@ -113,15 +108,8 @@ Include a dedicated section explaining: 1. Configure Scribe (`config/scribe.php`) 2. Annotate AuthController (most critical, used by all) -3. Annotate TeamMemberController -4. Annotate ProjectController -5. Annotate AllocationController -6. Annotate ActualController -7. Annotate CapacityController -8. Annotate ReportController -9. Annotate MasterDataController -10. Generate documentation (`php artisan scribe:generate`) -11. Verify SwaggerUI at `/api/documentation` +3. Generate documentation (`php artisan scribe:generate`) +4. Verify SwaggerUI at `/api/documentation` ## File Changes @@ -130,13 +118,7 @@ Include a dedicated section explaining: ### Modified Files - `backend/app/Http/Controllers/Api/AuthController.php` -- `backend/app/Http/Controllers/Api/TeamMemberController.php` -- `backend/app/Http/Controllers/Api/ProjectController.php` -- `backend/app/Http/Controllers/Api/AllocationController.php` -- `backend/app/Http/Controllers/Api/ActualController.php` -- `backend/app/Http/Controllers/Api/CapacityController.php` -- `backend/app/Http/Controllers/Api/ReportController.php` -- `backend/app/Http/Controllers/Api/MasterDataController.php` +- `backend/config/cors.php` ## Testing - Run `php artisan scribe:generate` - must complete without errors diff --git a/openspec/changes/p00-api-documentation/proposal.md b/openspec/changes/archive/2026-02-18-p00-api-documentation/proposal.md similarity index 71% rename from openspec/changes/p00-api-documentation/proposal.md rename to openspec/changes/archive/2026-02-18-p00-api-documentation/proposal.md index 913ddd06..bbf4709a 100644 --- a/openspec/changes/p00-api-documentation/proposal.md +++ b/openspec/changes/archive/2026-02-18-p00-api-documentation/proposal.md @@ -4,7 +4,7 @@ Add comprehensive API documentation annotations to all Laravel controllers using Laravel Scribe. This enables auto-generated SwaggerUI documentation accessible at `/api/documentation`. ## Goals -- Annotate ALL existing API controllers with Scribe annotations +- Annotate existing authentication API endpoints with Scribe annotations - Generate browsable API documentation - Ensure documentation stays in sync with implementation - Enable frontend developers to reference accurate API specs @@ -21,13 +21,8 @@ Add comprehensive API documentation annotations to all Laravel controllers using ### Controllers to Document 1. **AuthController** - Login, logout, token refresh endpoints -2. **TeamMemberController** - CRUD for team members -3. **ProjectController** - CRUD, status transitions, estimates -4. **AllocationController** - CRUD, bulk operations, matrix view -5. **ActualController** - CRUD, logging hours -6. **CapacityController** - Capacity calculations, holidays, PTO -7. **ReportController** - Forecast, utilization, costs, variance reports -8. **MasterDataController** - Roles, statuses, types management + +Other controller groups in the broader roadmap are deferred until those controllers/routes are present in this repository. ### Annotations Required Each endpoint must have: @@ -38,10 +33,10 @@ Each endpoint must have: - `@response 401|403|422` - Error responses ## Success Criteria -- [ ] All controllers have Scribe annotations +- [ ] AuthController endpoints have Scribe annotations - [ ] `php artisan scribe:generate` runs without errors - [ ] SwaggerUI accessible at `/api/documentation` -- [ ] All endpoints documented with request/response examples +- [ ] Authentication endpoints documented with request/response examples - [ ] Authentication section explains JWT flow ## Estimated Effort diff --git a/openspec/changes/archive/2026-02-18-p00-api-documentation/specs/api-documentation/spec.md b/openspec/changes/archive/2026-02-18-p00-api-documentation/specs/api-documentation/spec.md new file mode 100644 index 00000000..18a0e7c8 --- /dev/null +++ b/openspec/changes/archive/2026-02-18-p00-api-documentation/specs/api-documentation/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Generate Authentication API Documentation +The system SHALL generate API documentation for authentication endpoints using Laravel Scribe. + +#### Scenario: Generate docs successfully +- **WHEN** `php artisan scribe:generate` is run +- **THEN** the command completes without errors +- **AND** generated documentation includes `POST /api/auth/login`, `POST /api/auth/refresh`, and `POST /api/auth/logout` + +### Requirement: Serve Documentation UI +The system SHALL serve documentation at `/api/documentation`. + +#### Scenario: Access generated docs +- **WHEN** a client requests `GET /api/documentation` +- **THEN** the application responds with HTTP 200 +- **AND** the page contains the configured API title and authentication guidance diff --git a/openspec/changes/archive/2026-02-18-p00-api-documentation/tasks.md b/openspec/changes/archive/2026-02-18-p00-api-documentation/tasks.md new file mode 100644 index 00000000..6f895451 --- /dev/null +++ b/openspec/changes/archive/2026-02-18-p00-api-documentation/tasks.md @@ -0,0 +1,32 @@ +# Tasks: API Documentation with Scribe + +## Phase 1: Configure Scribe + +- [x] 0.1 Install Scribe (if not already installed): `composer require knuckleswtf/scribe` +- [x] 0.2 Publish Scribe config: `php artisan vendor:publish --tag=scribe-config` +- [x] 0.3 Configure `config/scribe.php` with Headroom settings +- [x] 0.4 Add `/api/documentation` to CORS allowed paths + +## Phase 2: Annotate Controllers + +### AuthController +- [x] 0.5 Add `@group Authentication` to class +- [x] 0.6 Document `POST /api/auth/login` with @bodyParam, @response +- [x] 0.7 Document `POST /api/auth/refresh` with @authenticated, @response +- [x] 0.8 Document `POST /api/auth/logout` with @authenticated, @response +- [x] 0.9 Add authentication section to Scribe config + +## Phase 3: Generate & Verify + +- [x] 0.54 Run `php artisan scribe:generate` +- [x] 0.55 Verify no errors in generation +- [x] 0.56 Access `/api/documentation` in browser +- [x] 0.57 Verify all endpoints appear in documentation +- [x] 0.58 Test "Try it out" for login endpoint +- [x] 0.59 Verify authentication flow is documented + +## Commits + +1. `chore(docs): Configure Laravel Scribe for API documentation` +2. `docs(api): Add Scribe annotations to AuthController` +3. `docs(api): Generate and verify SwaggerUI documentation` diff --git a/openspec/changes/p00-api-documentation/tasks.md b/openspec/changes/p00-api-documentation/tasks.md deleted file mode 100644 index 822f6aa6..00000000 --- a/openspec/changes/p00-api-documentation/tasks.md +++ /dev/null @@ -1,94 +0,0 @@ -# Tasks: API Documentation with Scribe - -## Phase 1: Configure Scribe - -- [x] 0.1 Install Scribe (if not already installed): `composer require knuckleswtf/scribe` -- [x] 0.2 Publish Scribe config: `php artisan vendor:publish --tag=scribe-config` -- [x] 0.3 Configure `config/scribe.php` with Headroom settings -- [x] 0.4 Add `/api/documentation` to CORS allowed paths - -## Phase 2: Annotate Controllers - -### AuthController -- [x] 0.5 Add `@group Authentication` to class -- [x] 0.6 Document `POST /api/auth/login` with @bodyParam, @response -- [x] 0.7 Document `POST /api/auth/refresh` with @authenticated, @response -- [x] 0.8 Document `POST /api/auth/logout` with @authenticated, @response -- [x] 0.9 Add authentication section to Scribe config - -### TeamMemberController -- [ ] 0.10 Add `@group Team Members` to class -- [ ] 0.11 Document `GET /api/team-members` with @queryParam filters -- [ ] 0.12 Document `POST /api/team-members` with @bodyParam -- [ ] 0.13 Document `GET /api/team-members/{id}` -- [ ] 0.14 Document `PUT /api/team-members/{id}` -- [ ] 0.15 Document `DELETE /api/team-members/{id}` - -### ProjectController -- [ ] 0.16 Add `@group Projects` to class -- [ ] 0.17 Document `GET /api/projects` with @queryParam filters -- [ ] 0.18 Document `POST /api/projects` with @bodyParam -- [ ] 0.19 Document `GET /api/projects/{id}` -- [ ] 0.20 Document `PUT /api/projects/{id}` -- [ ] 0.21 Document `PUT /api/projects/{id}/status` -- [ ] 0.22 Document `PUT /api/projects/{id}/estimate` -- [ ] 0.23 Document `PUT /api/projects/{id}/forecast` - -### AllocationController -- [ ] 0.24 Add `@group Allocations` to class -- [ ] 0.25 Document `GET /api/allocations` (matrix view) -- [ ] 0.26 Document `POST /api/allocations` -- [ ] 0.27 Document `PUT /api/allocations/{id}` -- [ ] 0.28 Document `DELETE /api/allocations/{id}` -- [ ] 0.29 Document `POST /api/allocations/bulk` - -### ActualController -- [ ] 0.30 Add `@group Actuals` to class -- [ ] 0.31 Document `GET /api/actuals` -- [ ] 0.32 Document `POST /api/actuals` -- [ ] 0.33 Document `PUT /api/actuals/{id}` -- [ ] 0.34 Document validation rules (future month rejection) - -### CapacityController -- [ ] 0.35 Add `@group Capacity` to class -- [ ] 0.36 Document `GET /api/capacity` -- [ ] 0.37 Document `GET /api/capacity/team` -- [ ] 0.38 Document `GET /api/capacity/revenue` -- [ ] 0.39 Document `POST /api/holidays` -- [ ] 0.40 Document `POST /api/ptos` - -### ReportController -- [ ] 0.41 Add `@group Reports` to class -- [ ] 0.42 Document `GET /api/reports/forecast` -- [ ] 0.43 Document `GET /api/reports/utilization` -- [ ] 0.44 Document `GET /api/reports/costs` -- [ ] 0.45 Document `GET /api/reports/variance` -- [ ] 0.46 Document `GET /api/reports/allocation` - -### MasterDataController -- [ ] 0.47 Add `@group Master Data` to class -- [ ] 0.48 Document `GET /api/roles` -- [ ] 0.49 Document `POST /api/roles` -- [ ] 0.50 Document `PUT /api/roles/{id}` -- [ ] 0.51 Document `DELETE /api/roles/{id}` -- [ ] 0.52 Document project-statuses endpoints -- [ ] 0.53 Document project-types endpoints - -## Phase 3: Generate & Verify - -- [ ] 0.54 Run `php artisan scribe:generate` -- [ ] 0.55 Verify no errors in generation -- [ ] 0.56 Access `/api/documentation` in browser -- [ ] 0.57 Verify all endpoints appear in documentation -- [ ] 0.58 Test "Try it out" for login endpoint -- [ ] 0.59 Verify authentication flow is documented - -## Commits - -1. `chore(docs): Configure Laravel Scribe for API documentation` -2. `docs(api): Add Scribe annotations to AuthController` -3. `docs(api): Add Scribe annotations to TeamMemberController` -4. `docs(api): Add Scribe annotations to ProjectController` -5. `docs(api): Add Scribe annotations to AllocationController` -6. `docs(api): Add Scribe annotations to remaining controllers` -7. `docs(api): Generate and verify SwaggerUI documentation` diff --git a/openspec/specs/api-documentation/spec.md b/openspec/specs/api-documentation/spec.md new file mode 100644 index 00000000..7ecdb6a3 --- /dev/null +++ b/openspec/specs/api-documentation/spec.md @@ -0,0 +1,22 @@ +# Capability: API Documentation + +## Purpose +Provide generated, accessible API documentation for currently implemented authentication endpoints. + +## Requirements + +### Requirement: Generate Authentication API Documentation +The system SHALL generate API documentation for authentication endpoints using Laravel Scribe. + +#### Scenario: Generate docs successfully +- **WHEN** `php artisan scribe:generate` is run +- **THEN** the command completes without errors +- **AND** generated documentation includes `POST /api/auth/login`, `POST /api/auth/refresh`, and `POST /api/auth/logout` + +### Requirement: Serve Documentation UI +The system SHALL serve documentation at `/api/documentation`. + +#### Scenario: Access generated docs +- **WHEN** a client requests `GET /api/documentation` +- **THEN** the application responds with HTTP 200 +- **AND** the page contains the configured API title and authentication guidance