From c2a292704960b301b53d941a186dd5cc59bffd57 Mon Sep 17 00:00:00 2001 From: Will Astilla <77816687+wastilla@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:58:28 -0400 Subject: [PATCH] 8 sol navigation (#11) Implemented forum router link so user can access forum page via the navigation bar --- backend/api/authentication.py | 67 ++++++++++- backend/api/profile.py | 26 +++- backend/services/user.py | 88 ++++++++++++-- docs/auth.md | 113 ++++++++++++++++++ docs/get_started.md | 4 + frontend/src/app/app-routing.module.ts | 2 + frontend/src/app/app.module.ts | 6 +- frontend/src/app/forum/forum.component.css | 0 frontend/src/app/forum/forum.component.html | 1 + .../src/app/forum/forum.component.spec.ts | 23 ++++ frontend/src/app/forum/forum.component.ts | 13 ++ .../app/navigation/navigation.component.html | 1 + 12 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 docs/auth.md create mode 100644 frontend/src/app/forum/forum.component.css create mode 100644 frontend/src/app/forum/forum.component.html create mode 100644 frontend/src/app/forum/forum.component.spec.ts create mode 100644 frontend/src/app/forum/forum.component.ts diff --git a/backend/api/authentication.py b/backend/api/authentication.py index 9a74be2..e89d2bb 100644 --- a/backend/api/authentication.py +++ b/backend/api/authentication.py @@ -1,3 +1,63 @@ +"""User authentication over HTTP via Headers and/or HTTP JWT Bearer Tokens. + +This module provides a `registered_user` dependency injection function for other routes +to use to both ensure a user is authenticated and resolve to the logged in User's model. +Further, this module provides the routes and logic for backend authentication. + +The router is mounted at `/auth` and provides the following endpoints: + + /auth + Redirects to the authentication server to authenticate the user. + + /auth/as/{uid}/{pid} + Redirects to the authentication server to authenticate the user as + another user. This route is only available in development mode. + + /auth/verify + Verifies the validity of a JWT token and returns the decoded token. This is + the end-point a development/staging server requests of the production server + to verify the legitimacy of delegated authentication. + +The implementation of auth routes are nuanced due to UNC Cloud Apps' Single Sign-On (SSO) +proxy service. In production, there is a proxy sitting in front of the app that +integrates with UNC's SSO/Shibboleth service for authentication. For more information +on this proxy service, see the [official CloudApps documentation](https://help.unc.edu/sp?id=kb_article_view&sysparm_article=KB0011256) + +In production, the proxy intercepts all routes prefixed with `/auth`. Two paths follow: + +1. If the user is not logged in, the proxy redirects the user to the authentication server. +2. If the user is logged in, the proxy sets the `uid` and `pid` headers to the user's + Onyen and PID, respectively, and forwards the request to our app. + +Once the request is forwarded to our app server, the `uid` and `pid` headers are used to +generate a JWT token and persist it in the client's local storage via a the _set_client_token +function. The frontend client code then uses this token, via JwtToken imported in AppModule, +to set the HTTP Authorization header on all subsequent API requests thanks to the @auth0/angular-jwt +library's [HTTP Interceptor](https://www.npmjs.com/package/@auth0/angular-jwt). + +In development, the proxy is not present. Instead, there are two options for authentication: + +1. If an unauthenticated user visits /auth in development, or staging, they are redirected + to the production `csxl.unc.edu/auth` route with an additional query parameter `origin`. + A. The production server authentication works as usual, but if the `origin` parameter is + detected alongside the SSO headers, the user will be redirected back to the `origin` + server with a JWT `token` query parameter. This token is signed by the production server. + B. Back on the development/staging server, we need to verify that the token given to the route + was actually signed by the production server. If we did not do this, a malicious user could + simply generate a token and pass it to the development server to gain access. Thus, an HTTP + request from the development/stage server is made to the production server's `/auth/verify` route + to verify the token's validity. If the token is valid, the development/staging server then + issues a new `token` to the client that is signed by the development/staging server. + This token is then used for all subsequent requests. +2. If an unauthenticated user visits /auth/as/{uid}/{pid} in development, they are authenticated + as the user with the given `uid` and `pid`, which are their ONYEN and PID, respectively. + This route is only available in development mode. + +Finally, the `authenticated_pid` function ensures a user is authenticated with PID and Onyen, +but does not require that the user be registered in the database. This is only really useful +for routes used in the process of registering a user. +""" + import jwt import requests from datetime import datetime, timedelta @@ -29,6 +89,7 @@ def registered_user( user_service: UserService = Depends(), token: HTTPAuthorizationCredentials | None = Depends(HTTPBearer()) ) -> User: + """Returns the authenticated user or raises a 401 HTTPException if the user is not authenticated.""" if token: try: auth_info = jwt.decode( @@ -44,6 +105,7 @@ def registered_user( def authenticated_pid( token: HTTPAuthorizationCredentials | None = Depends(HTTPBearer()) ) -> tuple[int, str]: + """Returns the authenticated user's PID and Onyen or raises a 401 HTTPException if the user is not authenticated.""" if token: try: auth_info = jwt.decode( @@ -54,7 +116,6 @@ def authenticated_pid( raise HTTPException(status_code=401, detail='Unauthorized') - @api.get('/verify') def auth_verify(token: str, continue_to: str = '/'): return jwt.decode(token, _JWT_SECRET, algorithms=[_JST_ALGORITHM], options={'verify_signature': True}) @@ -70,13 +131,15 @@ def bearer_token_bootstrap( origin: str | None = None, token: str | None = None, ): + """Handles authentication in both production and development. See the module docstring for more details.""" if request.url.path.startswith('/auth/as/'): # Authenticate as another user in development using special route. if getenv('MODE') == 'development': testing_authentication = True else: onyen = request.headers['uid'] - raise HTTPException(status_code=400, detail=f'Tsk, tsk. That is a naughty request {onyen}.') + raise HTTPException( + status_code=400, detail=f'Tsk, tsk. That is a naughty request {onyen}.') if HOST == AUTH_SERVER_HOST or ('testing_authentication' in locals() and testing_authentication): # Production App Request diff --git a/backend/api/profile.py b/backend/api/profile.py index 02cd1a5..c031287 100644 --- a/backend/api/profile.py +++ b/backend/api/profile.py @@ -1,15 +1,29 @@ +"""Profile API + +This API is used to retrieve and update a user's profile.""" + from fastapi import APIRouter, Depends from .authentication import authenticated_pid from ..services import UserService from ..models import User, NewUser, ProfileForm +__authors__ = ['Kris Jordan'] +__copyright__ = 'Copyright 2023' +__license__ = 'MIT' + api = APIRouter(prefix="/api/profile") PID = 0 ONYEN = 1 + @api.get("", response_model=User | NewUser, tags=['profile']) def read_profile(pid_onyen: tuple[int, str] = Depends(authenticated_pid), user_svc: UserService = Depends()): + """Retrieve a user's profile. If the user does not exist, return a NewUser. + + To handle new users, we rely only on the authenticated_pid dependency rather than + registered_user. + """ pid, onyen = pid_onyen user = user_svc.get(pid) if user: @@ -24,6 +38,15 @@ def update_profile( pid_onyen: tuple[int, str] = Depends(authenticated_pid), user_svc: UserService = Depends() ): + """Update a user's profile. If the user does not exist, create a new user. + + Since the user is authenticated, we can trust the pid and onyen. However, + since the user may not be registered, we depend on authenticated_pid rather + than registered_user. + + ProfileForm is used here, rather than User, for similar registration-specific + purposes. Importantly, ProfileForm doesn't contain an ID field. + """ pid, onyen = pid_onyen user = user_svc.get(pid) if user is None: @@ -35,11 +58,12 @@ def update_profile( email=profile.email, pronouns=profile.pronouns, ) + user = user_svc.create(user, user) else: user.first_name = profile.first_name user.last_name = profile.last_name user.email = profile.email user.pronouns = profile.pronouns user.onyen = onyen - user_svc.save(user) + user = user_svc.update(user, user) return user diff --git a/backend/services/user.py b/backend/services/user.py index 70ff872..3bf9f16 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -1,3 +1,8 @@ +"""User Service. + +The User Service provides access to the User model and its associated database operations. +""" + from fastapi import Depends from sqlalchemy import select, or_, func from sqlalchemy.orm import Session @@ -6,6 +11,10 @@ from ..entities import UserEntity from .permission import PermissionService +__authors__ = ['Kris Jordan'] +__copyright__ = 'Copyright 2023' +__license__ = 'MIT' + class UserService: @@ -13,10 +22,19 @@ class UserService: _permission: PermissionService def __init__(self, session: Session = Depends(db_session), permission: PermissionService = Depends()): + """Initialize the User Service.""" self._session = session self._permission = permission def get(self, pid: int) -> User | None: + """Get a User by PID. + + Args: + pid: The PID of the user. + + Returns: + User | None: The user or None if not found. + """ query = select(UserEntity).where(UserEntity.pid == pid) user_entity: UserEntity = self._session.scalar(query) if user_entity is None: @@ -26,7 +44,16 @@ def get(self, pid: int) -> User | None: model.permissions = self._permission.get_permissions(model) return model - def search(self, subject: User, query: str) -> list[User]: + def search(self, _subject: User, query: str) -> list[User]: + """Search for users by their name, onyen, email. + + Args: + subject: The user performing the action. + query: The search query. + + Returns: + list[User]: The list of users matching the query. + """ statement = select(UserEntity) criteria = or_( UserEntity.first_name.ilike(f'%{query}%'), @@ -39,6 +66,19 @@ def search(self, subject: User, query: str) -> list[User]: return [entity.to_model() for entity in entities] def list(self, subject: User, pagination_params: PaginationParams) -> Paginated[User]: + """List Users. + + The subject must have the 'user.list' permission on the 'user/' resource. + + Args: + subject: The user performing the action. + pagination_params: The pagination parameters. + + Returns: + Paginated[User]: The paginated list of users. + + Raises: + PermissionError: If the subject does not have the required permission.""" self._permission.enforce(subject, 'user.list', 'user/') statement = select(UserEntity) @@ -67,12 +107,44 @@ def list(self, subject: User, pagination_params: PaginationParams) -> Paginated[ return Paginated(items=[entity.to_model() for entity in entities], length=length, params=pagination_params) - def save(self, user: User) -> User | None: - if user.id: - entity = self._session.get(UserEntity, user.id) - entity.update(user) - else: - entity = UserEntity.from_model(user) - self._session.add(entity) + def create(self, subject: User, user: User) -> User: + """Create a User. + + If the subject is not the user, the subject must have the `user.create` permission. + + Args: + subject: The user performing the action. + user: The user to create. + + Returns: + The created User. + + Raises: + PermissionError: If the subject does not have permission to create the user.""" + if subject != user: + self._permission.enforce(subject, 'user.create', 'user/') + entity = UserEntity.from_model(user) + self._session.add(entity) + self._session.commit() + return entity.to_model() + + def update(self, subject: User, user: User) -> User: + """Update a User. + + If the subject is not the user, the subject must have the `user.update` permission. + + Args: + subject: The user performing the action. + user: The user to update. + + Returns: + The updated User. + + Raises: + PermissionError: If the subject does not have permission to update the user.""" + if subject != user: + self._permission.enforce(subject, 'user.update', f'user/{user.id}') + entity = self._session.get(UserEntity, user.id) + entity.update(user) self._session.commit() return entity.to_model() diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..962a8d0 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,113 @@ +# Authentication and Authorization + +This document is for CSXL Developers who need authentication and authorization in their feature work. + +## Table of Contents + +* [Authentication](#authentication) +* [Authorization](#authorization) + * [Feature-specific rules](#1-feature-specific-rules) + * [Administrative Permission Rules](#2-administrative-permission-rules) +* [Common Development Concerns](#common-development-concerns) + * [Backend Routes Requiring a Registered User](#backend-routes-requiring-a-registered-user) + * [Testing Protected Routes via OpenAPI](#testing-authenticated-routes-via-openapi) + * [Protecting Backend Service Methods](#protecting-backend-service-methods) + * [Frontend Features Requiring a Registered User](#frontend-features-requiring-a-registered-user) + * [Frontend Features Requiring Authorization](#frontend-features-requiring-authorization) + +## Authentication + +Authentication in `csxl.unc.edu` is integrated with UNC's Single Sign-on (SSO) Shibboleth service. This allows username, password, and UNC affinity to be handled by UNC ITS and our application takes a dependency upon it. For more information on SSO, see ITS' [official documentation](https://its.unc.edu/2017/07/24/shibboleth/). For the implementation details on *how* authentication works in this application, see [backend/api/authentication.py](backend/api/authentication.py). + +Authentication is verifying *who* the "subject" accessing a system is. The term "subject" is chosen intentionally in the security lexicon. A subject may be a person, but alternatively an automated program accessing a system on behalf of a person, group, or organization. The CSXL application is, for now, foremost a user-facing application that serves the people of the computer science department at UNC. Thus, a "subject" is a person and user of the CSXL application for our concerns. + +## Authorization + +Authorization is verifying a subject/user *has permission* to carry out an *action* on a *resource* within the system. For example, the leader of a workshop may have permission to edit a workshop's details, whereas a registered participant of a workshop would not. Additionally, a site administrator may every permission possible, whereas a newly registered user does not. + +Authorization concerns in the `csxl.unc.edu` application can be thought of as the union of two distinct rule sets: + +## 1. Feature-specific Rules + +When a feature of the website, via one or more of its models, is related to one or more users in the system, it is likely these users will need authorization to carry out specific actions on these models. This authorization is achieved via feature-specific rules. For example, a user who has registered for a workshop should be able to unregister themselves if a conflict has arisen. This user should not be able to unregister *other* users, though. A workshop leader may be able to modify the details of *their* workshop, but not someone else's. + +The logic for enforcing feature-specific concerns should be specified in the feature's backend service layer methods. Developers are encouraged to factor out this logic into reusable helper functions; it is likely many service methods will rely upon the same logic. + +All backend service layer methods with authorization concerns should accept a `subject: User` as their first parameter. This represents the user attempting to carry out the action and whose authorization needs verification. If your backend service layer method determines the subject does not have permission to carry out the operation, raise a [`backend.services.permission.UserPermissionError`](backend/services/permission.py). Example usage of this exception: + +```python +raise UserPermissionError('workshops.update', f'workshops/{workshop.id}`) +``` + +For administrative concerns discussed next, the first argument is conventionally specified as `service.method` and the second as the target *path* of the primary model being operated on, without the leading `api/`. In the above example, you could assume `/api/workshops/1` was the FastAPI path to the model being operated on. + +## 2. Administrative Permission Rules + +The second kind of authorization rules are administrative permissions. For example, a site administrator needs permission to carry out any action on every resource. Alternatively, the Workshop Administrator needs to be able to create new workshops, assign workshop leads, and edit any of them. Administrative permissions are built into the site via Roles and Permissions. + +The facilities for this kind of authorization is built into the site. Feature developers need to use the Permission API to check for administrative permissions where appropriate. Generally, there are two appropriate places for administrative permisssion rule enforcement: + +A. Everywhere there is feature-specific authorization rule there should be a check for administrative permission. Rule-of-thumb, everywhere your feature raises a `UserPermissionError`, you should also check for the corresponding administrative permission rule before raising the error. + +B. Admin-only aspects of a feature. + +Permissions are assigned to Roles and Users can be members of many Roles. A Permission *grants* access to carry out action(s) over resource(s). The action and resource are specified as strings where the action refers to a protected backend service method and the resource refers to a model's path. Permissions strings can be specified with wildcard asterisks implying "match all". + +To see how administrative permissions are managed in the app, in the development environment, after resetting the database, sign in as the [Super User](http://localhost:1560/auth/as/root/999999999) and go to the Admin > Roles page. Open the Staff role to see it has permissions to action `role.*` on resource `*`. The `*` implies "matches anything following". Thus, users with the Staff role have permission to carry out any action in the `services.role` service on all roles. You can see "Merritt Manager" is a user who has "Staff" role capabilities. If you navigate back to Roles and then to the "Sudoers" role, you will see the "Super User" you are signed in as has authorization for all actions on all resources. + +## Common Development Concerns + +### Backend Routes Requiring a Registered User + +Thanks to FastAPI's dependency injection system and the `registered_user` helper function in [backend/api/authentication.py], adding authentication to a route is as easy as adding a parameter. For example, [backend/api/roles.py]'s `list_roles` function is defined as: + +```python +@api.get("", tags=["Roles"]) +def list_roles( + subject: User = Depends(registered_user), + role_service: RoleService = Depends(), +) -> list[Role]: + ... +``` + +By adding the parameter `subject`, which *depends* on the `registered_user` helper function, FastAPI's dependency injection system automatically calls `registered_user`, which in turn depends on the authentication bearer token set during sign in and a corresponding registered user existing in the database. Thus, within the route function, `subject` is bound to the current signed in User. By adding this parameter, you will see the OpenAPI routes automatically become protected. + +### Testing Authenticated Routes via OpenAPI + +To use authorization protected routes via OpenAPI at `/docs`, you will need to authenticate yourself by adding your signed-in HTTP Bearer Token. + +To find your token, which our application persists in `localStorage`: + +1. Login to your development application via the front-end +2. Open Developer Tools +3. Go to Application > Storage > Local Storage > localhost:1560 +4. Copy the *full* value associated with the `bearerToken` key. + +In the OpenAPI user interface found at `/docs`, look for the Green Authorize button and paste in your bearer token. + +### Protecting Backend Service Methods + +Backend service methods are _the most important place_ to correctly verify authorization. Failing to properly verify authorization here means users will be able to take actions they should not have permission to. + +As an example, consider _updating a user's profile details_. The "feature" is a user's profile. The feature-specific rule is _a user can update their own profile_. This verification is implemented in [backend/services/user.py](https://github.com/unc-csxl/csxl.unc.edu/blob/e349bd727f5525a07dc85ed602916470b285e24f/backend/services/user.py#L145). Notice the negation of the rule is specified in the `if` such that if the rule is `True` (the user is the subject), execution carries on into the method. However, if the feature-specific rule does not hold, we then call the [`PermissionService`](backend/services/permission.py)'s `enforce` method, giving it the `subject` user, action string (`user.update`), and resource (`user/{id}`). This method handles the logic for checking whether `subject` has administrative access to carry out this action on the resource. If the `subject` does, this procedure returns nothing. If they do not, it raises a `UserPermissionError` for you. This demonstrates an idiomatic way of verifying the `subject` is authorized. + +If your feature-specific rules are more involved than a simple equality check, you should refactor these rules out into a method of its own with a well chosen name. This will help keep your service's methods easier to read and reason through. Additionally, it makes it easier to write unit tests specifically targetting your feature-specific rule logic. + +### Frontend Features Requiring a Registered User + +To test whether a user is signed in on the frontend Angular application, your Component can +use dependency injection to gain access to the [`ProfileService`](https://github.com/unc-csxl/csxl.unc.edu/blob/main/frontend/src/app/profile/profile.service.ts). The `ProfileService` provides a public member `profile$`, of type `Observable` which your components can subscribe to from their templates. + +As an example of this, consider [`NavigationComponent`](frontend/src/app/navigation): + +1. [The `NavigationComponent` projects `profile$` as a public property of its own. This property is initialized in the constructor.](https://github.com/unc-csxl/csxl.unc.edu/blob/e349bd727f5525a07dc85ed602916470b285e24f/frontend/src/app/navigation/navigation.component.ts#L39) +2. [The `NavigationComponent`'s template subscribes to `profile$` with an async pipe and uses `ngIf` to show "Sign In" versus the navigation items shown to a user who is signed in.](https://github.com/unc-csxl/csxl.unc.edu/blob/main/frontend/src/app/navigation/navigation.component.html#L11) + +### Frontend Features Requiring Authorization + +For feature-specific rule authorization, the alpha version of the CSXL web app that COMP590 Spring 2023 is starting from does not yet have an idiomatic example in the frontend. As a feature developer, you will need to come up with a solution of how your frontend UI will handle feature-specific authorization concerns. + +For administrative permission rule authorization, the [PermissionService](frontend/src/app/permission.service.ts) provides helper methods for verifying administrative permissions. For an idiomatic example use case for administrative permission checking, see [`NavigationComponent`](frontend/src/app/navigation)'s `adminPermission$` `Observable`: + +1. [The permission is initialized in the constructor via `PermissionService`'s `check` method.](https://github.com/unc-csxl/csxl.unc.edu/blob/e349bd727f5525a07dc85ed602916470b285e24f/frontend/src/app/navigation/navigation.component.ts#L41) +2. [The permission is checked in the HTML template using an async pipe.](https://github.com/unc-csxl/csxl.unc.edu/blob/main/frontend/src/app/navigation/navigation.component.html#L13) \ No newline at end of file diff --git a/docs/get_started.md b/docs/get_started.md index 9999b52..7523b22 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -48,6 +48,10 @@ Once the Dev Container begins, open a terminal and complete the following: Before beginning any feature work, fixes, or other modifications, you should checkout a branch to keep the history separate from the `main` line history until it is ready deploying into production. +## Adding Authorization Restricted Features + +See [docs/auth.md] for more detailed information on authentication and authorization development concerns. + ## Authorize as Alternate Users When running in a development environment, it is helpful to be able to switch between authenticated users. diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 321f986..0acaef7 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,12 +4,14 @@ import { AppTitleStrategy } from './app-title.strategy'; import { GateComponent } from './gate/gate.component'; import { HomeComponent } from './home/home.component'; import { ProfileEditorComponent } from './profile/profile-editor/profile-editor.component'; +import { ForumComponent } from './forum/forum.component'; const routes: Routes = [ HomeComponent.Route, ProfileEditorComponent.Route, GateComponent.Route, + ForumComponent.Route, { path: 'admin', title: 'Admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }, ]; diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 5581bf3..72bccfe 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -32,7 +32,8 @@ import { NavigationComponent } from './navigation/navigation.component'; import { ErrorDialogComponent } from './navigation/error-dialog/error-dialog.component'; import { HomeComponent } from './home/home.component'; import { GateComponent } from './gate/gate.component'; -import { ProfileEditorComponent } from './profile/profile-editor/profile-editor.component'; +import { ProfileEditorComponent } from './profile/profile-editor/profile-editor.component'; +import { ForumComponent } from './forum/forum.component'; @NgModule({ declarations: [ @@ -41,7 +42,8 @@ import { ProfileEditorComponent } from './profile/profile-editor/profile-editor. ErrorDialogComponent, HomeComponent, GateComponent, - ProfileEditorComponent + ProfileEditorComponent, + ForumComponent ], imports: [ BrowserModule, diff --git a/frontend/src/app/forum/forum.component.css b/frontend/src/app/forum/forum.component.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/forum/forum.component.html b/frontend/src/app/forum/forum.component.html new file mode 100644 index 0000000..0ca0912 --- /dev/null +++ b/frontend/src/app/forum/forum.component.html @@ -0,0 +1 @@ +

Welcome to the Forum!

diff --git a/frontend/src/app/forum/forum.component.spec.ts b/frontend/src/app/forum/forum.component.spec.ts new file mode 100644 index 0000000..df9ae41 --- /dev/null +++ b/frontend/src/app/forum/forum.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ForumComponent } from './forum.component'; + +describe('ForumComponent', () => { + let component: ForumComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ForumComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ForumComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/forum/forum.component.ts b/frontend/src/app/forum/forum.component.ts new file mode 100644 index 0000000..edfd79e --- /dev/null +++ b/frontend/src/app/forum/forum.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-forum', + templateUrl: './forum.component.html', + styleUrls: ['./forum.component.css'] +}) +export class ForumComponent { + public static Route = { + path: 'forum', + component: ForumComponent + }; +} diff --git a/frontend/src/app/navigation/navigation.component.html b/frontend/src/app/navigation/navigation.component.html index b7b2594..d8e22c0 100644 --- a/frontend/src/app/navigation/navigation.component.html +++ b/frontend/src/app/navigation/navigation.component.html @@ -14,6 +14,7 @@ Profile Sign out + Forum