= ({ formField, statusList, severityList, incidentTypes }) => {
24 | const inputComponent = getFormFieldComponent(formField.id, formField, statusList, severityList, incidentTypes)
25 | const description = formField.description
26 | ? formField.description
27 | : formField.field.description
28 | ? formField.field.description
29 | : undefined
30 |
31 | return (
32 |
33 |
36 | {inputComponent}
37 | {description && {description}}
38 |
39 | )
40 | }
41 |
42 | export default FormField
43 |
--------------------------------------------------------------------------------
/frontend/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* Box sizing rules */
2 | *,
3 | *::before,
4 | *::after {
5 | box-sizing: border-box;
6 | }
7 |
8 | /* Remove default margin */
9 | body,
10 | h1,
11 | h2,
12 | h3,
13 | h4,
14 | p,
15 | figure,
16 | blockquote,
17 | dl,
18 | dd {
19 | margin: 0;
20 | }
21 |
22 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
23 | ul[role='list'],
24 | ol[role='list'] {
25 | list-style: none;
26 | }
27 |
28 | /* Set core root defaults */
29 | html {
30 | scroll-behavior: smooth;
31 | }
32 |
33 | /* Set core body defaults */
34 | body {
35 | min-height: 100vh;
36 | text-rendering: optimizeSpeed;
37 | line-height: 1.5;
38 | }
39 |
40 | /* A elements that don't have a class get default styles */
41 | a:not([class]) {
42 | text-decoration-skip-ink: auto;
43 | }
44 |
45 | /* Make images easier to work with */
46 | img,
47 | picture {
48 | max-width: 100%;
49 | display: block;
50 | }
51 |
52 | /* Inherit fonts for inputs and buttons */
53 | input,
54 | button,
55 | textarea,
56 | select {
57 | font: inherit;
58 | }
59 |
60 | /* Remove all animations and transitions for people that prefer not to see them */
61 | @media (prefers-reduced-motion: reduce) {
62 | *,
63 | *::before,
64 | *::after {
65 | animation-duration: 0.01ms !important;
66 | animation-iteration-count: 1 !important;
67 | transition-duration: 0.01ms !important;
68 | scroll-behavior: auto !important;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/status-page/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import Image from "next/image";
3 |
4 | import logo from "@/app/icon.png";
5 | import { useContext } from "react";
6 | import { StatusPageContext } from "@/app/StatusPageProvider";
7 |
8 | const Root = styled.div`
9 | display: flex;
10 | flex-direction: column;
11 | margin-top: 2rem;
12 | margin-bottom: 2rem;
13 | align-items: center;
14 | `;
15 | const Row = styled.div`
16 | display: flex;
17 | gap: 0.5rem;
18 | align-items: center;
19 | margin-bottom: 2rem;
20 | `;
21 |
22 | const Footer = () => {
23 | const context = useContext(StatusPageContext);
24 | if (!context) {
25 | return null;
26 | }
27 |
28 | return (
29 |
30 |
31 | Powered by
32 |
35 |
36 |
37 |
38 |
39 |
40 | {context.statusPage.privacyPolicyUrl && (
41 |
44 | )}
45 | {context.statusPage.termsOfServiceUrl && (
46 |
49 | )}
50 |
51 |
52 | );
53 | };
54 |
55 | export default Footer;
56 |
--------------------------------------------------------------------------------
/backend/app/tasks/invite_user_to_channel.py:
--------------------------------------------------------------------------------
1 | import structlog
2 | from slack_sdk import WebClient
3 | from slack_sdk.errors import SlackApiError
4 |
5 | from app.repos import OrganisationRepo, UserRepo
6 | from app.schemas.tasks import InviteUserToChannelParams
7 |
8 | from .base import BaseTask
9 |
10 | logger = structlog.get_logger(logger_name=__name__)
11 |
12 |
13 | class InviteUserToChannelTask(BaseTask["InviteUserToChannelParams"]):
14 | def execute(self, parameters: "InviteUserToChannelParams"):
15 | user_repo = UserRepo(session=self.session)
16 | organisation_repo = OrganisationRepo(session=self.session)
17 |
18 | user = user_repo.get_by_id_or_raise(parameters.user_id)
19 | organisation = organisation_repo.get_by_id_or_raise(parameters.organisation_id)
20 |
21 | client = WebClient(token=organisation.slack_bot_token)
22 |
23 | # app must be in channel first
24 | try:
25 | client.conversations_join(channel=parameters.slack_channel_id)
26 | except SlackApiError:
27 | raise
28 |
29 | # then invite creator of incident to the announcements channel
30 | try:
31 | client.conversations_invite(channel=parameters.slack_channel_id, users=[user.slack_user_id])
32 | except SlackApiError as e:
33 | if e.response.get("error") == "already_in_channel":
34 | logger.warning("user is already in channel")
35 | else:
36 | raise
37 |
--------------------------------------------------------------------------------
/backend/migrations/versions/2024_12_24_2308-e4af2032001a_status_page_urls.py:
--------------------------------------------------------------------------------
1 | """status page urls
2 |
3 | Revision ID: e4af2032001a
4 | Revises: 3a0d4e196a08
5 | Create Date: 2024-12-24 23:08:31.473386
6 |
7 | """
8 |
9 | from typing import Sequence, Union
10 |
11 | import sqlalchemy as sa
12 | from alembic import op
13 |
14 | # revision identifiers, used by Alembic.
15 | revision: str = "e4af2032001a"
16 | down_revision: Union[str, None] = "3a0d4e196a08"
17 | branch_labels: Union[str, Sequence[str], None] = None
18 | depends_on: Union[str, Sequence[str], None] = None
19 |
20 |
21 | def upgrade() -> None:
22 | # ### commands auto generated by Alembic - please adjust! ###
23 | op.add_column("status_page", sa.Column("support_url", sa.String(), nullable=True))
24 | op.add_column("status_page", sa.Column("support_label", sa.String(), nullable=False, server_default="Support"))
25 | op.add_column("status_page", sa.Column("privacy_policy_url", sa.String(), nullable=True))
26 | op.add_column("status_page", sa.Column("terms_of_service_url", sa.String(), nullable=True))
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_column("status_page", "terms_of_service_url")
33 | op.drop_column("status_page", "privacy_policy_url")
34 | op.drop_column("status_page", "support_url")
35 | op.drop_column("status_page", "support_label")
36 | # ### end Alembic commands ###
37 |
--------------------------------------------------------------------------------
/status-page/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* Box sizing rules */
2 | *,
3 | *::before,
4 | *::after {
5 | box-sizing: border-box;
6 | }
7 |
8 | /* Remove default margin */
9 | body,
10 | h1,
11 | h2,
12 | h3,
13 | h4,
14 | p,
15 | figure,
16 | blockquote,
17 | dl,
18 | dd {
19 | margin: 0;
20 | }
21 |
22 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
23 | ul[role='list'],
24 | ol[role='list'] {
25 | list-style: none;
26 | }
27 |
28 | /* Set core root defaults */
29 | html {
30 | scroll-behavior: smooth;
31 | }
32 |
33 | /* Set core body defaults */
34 | body {
35 | min-height: 100vh;
36 | text-rendering: optimizeSpeed;
37 | line-height: 1.5;
38 | }
39 |
40 | /* A elements that don't have a class get default styles */
41 | a:not([class]) {
42 | text-decoration-skip-ink: auto;
43 | }
44 |
45 | /* Make images easier to work with */
46 | img,
47 | picture {
48 | max-width: 100%;
49 | display: block;
50 | }
51 |
52 | /* Inherit fonts for inputs and buttons */
53 | input,
54 | button,
55 | textarea,
56 | select {
57 | font: inherit;
58 | }
59 |
60 | /* Remove all animations and transitions for people that prefer not to see them */
61 | @media (prefers-reduced-motion: reduce) {
62 | *,
63 | *::before,
64 | *::after {
65 | animation-duration: 0.01ms !important;
66 | animation-iteration-count: 1 !important;
67 | transition-duration: 0.01ms !important;
68 | scroll-behavior: auto !important;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/backend/app/models/incident_type.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | from sqlalchemy import Boolean, ForeignKey, String, UnicodeText
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 |
6 | from app.db import Base
7 |
8 | from .mixins import SoftDeleteMixin, TimestampMixin
9 |
10 | if typing.TYPE_CHECKING:
11 | from .field import Field
12 | from .organisation import Organisation
13 |
14 |
15 | class IncidentType(Base, TimestampMixin, SoftDeleteMixin):
16 | __prefix__ = "type"
17 |
18 | organisation_id: Mapped[str] = mapped_column(
19 | String(50), ForeignKey("organisation.id", ondelete="cascade"), nullable=False, index=True
20 | )
21 | name: Mapped[str] = mapped_column(UnicodeText, nullable=False)
22 | description: Mapped[str] = mapped_column(UnicodeText, nullable=False)
23 | is_editable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
24 | is_deletable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
25 | is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
26 |
27 | # relationships
28 | organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="incident_types")
29 |
30 | # fields that have not been deleted
31 | fields: Mapped[list["Field"]] = relationship(
32 | "Field",
33 | secondary="incident_type_field",
34 | secondaryjoin="and_(Field.id==incident_type_field.c.field_id, Field.deleted_at.is_(None))",
35 | )
36 |
--------------------------------------------------------------------------------
/backend/app/models/announcement.py:
--------------------------------------------------------------------------------
1 | import enum
2 | import typing
3 |
4 | from sqlalchemy import ForeignKey, String
5 | from sqlalchemy.dialects.postgresql import JSONB
6 | from sqlalchemy.orm import Mapped, mapped_column, relationship
7 |
8 | from app.db import Base
9 |
10 | from .mixins import SoftDeleteMixin, TimestampMixin
11 |
12 | if typing.TYPE_CHECKING:
13 | from .organisation import Organisation
14 | from .slack_message import SlackMessage
15 |
16 |
17 | class AnnouncementFields(str, enum.Enum):
18 | TYPE = "TYPE"
19 | SEVERITY = "SEVERITY"
20 | STATUS = "STATUS"
21 | REPORTER = "REPORTER"
22 | INCIDENT_LEAD = "INCIDENT_LEAD"
23 | SLACK_CHANNEL = "SLACK_CHANNEL"
24 |
25 |
26 | class AnnouncementActions(str, enum.Enum):
27 | HOMEPAGE = "HOMEPAGE"
28 |
29 |
30 | class Announcement(Base, TimestampMixin, SoftDeleteMixin):
31 | __prefix__ = "ann"
32 |
33 | organisation_id: Mapped[str] = mapped_column(
34 | String(50), ForeignKey("organisation.id", ondelete="cascade"), nullable=False, index=True
35 | )
36 | fields: Mapped[list[AnnouncementFields]] = mapped_column(JSONB, nullable=False, default=[])
37 | actions: Mapped[list[AnnouncementActions]] = mapped_column(JSONB, nullable=False, default=[])
38 |
39 | # relationships
40 | organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="announcements")
41 | slack_messages: Mapped[list["SlackMessage"]] = relationship("SlackMessage", back_populates="announcement")
42 |
--------------------------------------------------------------------------------
/frontend/src/pages/Auth/LoginSelector.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { Link } from 'react-router-dom'
3 | import styled from 'styled-components'
4 |
5 | import email from '@/assets/icons/email.svg'
6 | import slack from '@/assets/icons/slack.svg'
7 | import logo from '@/assets/mark_noborder.png'
8 | import Icon from '@/components/Icon/Icon'
9 | import useApiService from '@/hooks/useApi'
10 | import { RoutePaths } from '@/routes'
11 |
12 | import { Logo, Root } from './styles'
13 |
14 | const LoginButton = styled(Link)`
15 | padding: 1rem;
16 | display: flex;
17 | margin: 1rem;
18 | background-color: #fff;
19 | border: 1px solid var(--color-gray-300);
20 | gap: 1rem;
21 | align-items: center;
22 | justify-content: center;
23 | `
24 |
25 | const LoginSelector: React.FC = () => {
26 | const { apiService } = useApiService()
27 | const slackLoginQuery = useQuery({
28 | queryKey: ['slack-login-url'],
29 | queryFn: () => apiService.slackLoginUrl()
30 | })
31 | return (
32 |
33 |
34 | Welcome
35 |
36 |
37 | Email login
38 |
39 |
40 | Slack login
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | export default LoginSelector
48 |
--------------------------------------------------------------------------------
/frontend/src/pages/Auth/Register.tsx:
--------------------------------------------------------------------------------
1 | import { FormikHelpers } from 'formik'
2 | import { Link, useNavigate } from 'react-router-dom'
3 |
4 | import logo from '@/assets/mark_noborder.png'
5 | import RegisterForm, { RegisterFormValues } from '@/components/RegisterForm/RegisterForm'
6 | import useApiService from '@/hooks/useApi'
7 | import { RoutePaths } from '@/routes'
8 | import { APIError } from '@/services/transport'
9 | import { apiErrorsToFormikErrors } from '@/utils/form'
10 |
11 | import { Content, FooterMessage, Logo, Root } from './styles'
12 |
13 | const Register: React.FC = () => {
14 | const navigate = useNavigate()
15 | const { apiService } = useApiService()
16 |
17 | const handleSubmit = async (values: RegisterFormValues, { setErrors }: FormikHelpers) => {
18 | try {
19 | await apiService.createUser(values)
20 | navigate(RoutePaths.REGISTER_SUCCESS)
21 | } catch (e) {
22 | if (e instanceof APIError) {
23 | setErrors(apiErrorsToFormikErrors(e))
24 | }
25 | console.error(e)
26 | }
27 | }
28 |
29 | return (
30 |
31 |
32 | Create new account
33 |
34 |
35 |
36 |
37 |
38 | Already have an account? Login here{' '}
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default Register
46 |
--------------------------------------------------------------------------------
/frontend/src/pages/StatusPages/modals/CreateIncidentModal.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 |
3 | import Dialog from '@/components/Dialog/Dialog'
4 | import useApiService from '@/hooks/useApi'
5 | import { CreateStatusPageIncident } from '@/types/action'
6 | import { IStatusPage, ModelID } from '@/types/models'
7 |
8 | import CreateStatusPageIncidentForm, { FormValues } from '../components/CreateStatusPageIncidentForm'
9 |
10 | interface Props {
11 | statusPage: IStatusPage
12 | onClose: () => void
13 | }
14 |
15 | const CreateIncidentModal: React.FC = ({ onClose, statusPage }) => {
16 | const { apiService } = useApiService()
17 | const queryClient = useQueryClient()
18 |
19 | const mutation = useMutation({
20 | mutationFn: ({ value, id }: { value: CreateStatusPageIncident; id: ModelID }) =>
21 | apiService.createStatusPageIncident(id, value),
22 | onSuccess: () => {
23 | queryClient.invalidateQueries({
24 | queryKey: ['get-active-status-page-incidents', statusPage.id]
25 | })
26 | onClose()
27 | }
28 | })
29 |
30 | const handleSubmit = (values: FormValues) => {
31 | mutation.mutateAsync({
32 | id: statusPage.id,
33 | value: values
34 | })
35 | }
36 |
37 | return (
38 |
41 | )
42 | }
43 |
44 | export default CreateIncidentModal
45 |
--------------------------------------------------------------------------------
/backend/app/services/vercel/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from pydantic import BaseModel, ConfigDict
4 | from pydantic.alias_generators import to_camel
5 |
6 |
7 | class BaseVercelModel(BaseModel):
8 | model_config = ConfigDict(
9 | alias_generator=to_camel,
10 | from_attributes=True,
11 | populate_by_name=True,
12 | extra="forbid",
13 | )
14 |
15 |
16 | class Pagination(BaseVercelModel):
17 | count: int
18 | next: int | None
19 | prev: int | None
20 |
21 |
22 | class ProjectDomainVerificationChallenge(BaseVercelModel):
23 | domain: str
24 | reason: str
25 | type: str
26 | value: str
27 |
28 |
29 | class ProjectDomain(BaseVercelModel):
30 | apex_name: str
31 | created_at: datetime
32 | custom_environment_id: str | None = None
33 | git_branch: str | None = None
34 | name: str
35 | project_id: str
36 | redirect: str | None = None
37 | redirect_status_code: int | None = None
38 | updated_at: datetime
39 | verification: list[ProjectDomainVerificationChallenge] | None = None
40 | verified: bool
41 |
42 |
43 | class DomainConfig(BaseVercelModel):
44 | configured_by: str | None = None
45 | nameservers: list[str]
46 | service_type: str
47 | cnames: list[str]
48 | a_values: list[str]
49 | conflicts: list[str]
50 | accepted_challenges: list[str]
51 | misconfigured: bool
52 |
53 |
54 | class ListProjectDomainsResponse(BaseVercelModel):
55 | pagination: Pagination
56 | domains: list[ProjectDomain]
57 |
--------------------------------------------------------------------------------
/frontend/src/components/Team/TeamForm.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Formik, FormikHelpers } from 'formik'
2 | import * as Yup from 'yup'
3 |
4 | import Field from '@/components/Form/Field'
5 | import GeneralError from '@/components/Form/GeneralError'
6 | import { StyledButton } from '@/components/Theme/Styles'
7 | import { ITeam } from '@/types/models'
8 |
9 | interface Props {
10 | team?: ITeam
11 | onSubmit: (values: TeamFormValues, helpers: FormikHelpers) => void
12 | }
13 |
14 | export type TeamFormValues = {
15 | name: string
16 | }
17 |
18 | const validationSchema = Yup.object().shape({
19 | name: Yup.string().required('Enter a name of this team')
20 | })
21 |
22 | const TeamForm: React.FC = ({ onSubmit, team }) => {
23 | const defaultValues = {
24 | name: team ? team.name : ''
25 | }
26 |
27 | return (
28 | validationSchema={validationSchema} initialValues={defaultValues} onSubmit={onSubmit}>
29 | {({ isSubmitting }) => (
30 |
42 | )}
43 |
44 | )
45 | }
46 |
47 | export default TeamForm
48 |
--------------------------------------------------------------------------------
/frontend/src/components/Sections/SwitchOrganisationForm.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Formik, FormikHelpers } from 'formik'
2 | import { useMemo } from 'react'
3 | import * as Yup from 'yup'
4 |
5 | import { IOrganisation } from '@/types/models'
6 |
7 | import SelectField from '../Form/SelectField'
8 |
9 | interface Props {
10 | currentOrganisation: IOrganisation
11 | organisations: IOrganisation[]
12 | onSubmit: (values: FormValues, helpers: FormikHelpers) => void
13 | }
14 |
15 | export interface FormValues {
16 | organisationId: string
17 | }
18 |
19 | const validationSchema = Yup.object().shape({
20 | organisationId: Yup.string().required('Please select an organisation')
21 | })
22 |
23 | const SwitchOrganisationForm: React.FC = ({ currentOrganisation, organisations, onSubmit }) => {
24 | const defaultValues = {
25 | organisationId: currentOrganisation.id
26 | }
27 | const options = useMemo(
28 | () =>
29 | organisations.map((it) => ({
30 | label: it.name,
31 | value: it.id
32 | })),
33 | [organisations]
34 | )
35 |
36 | return (
37 |
38 | validationSchema={validationSchema}
39 | initialValues={defaultValues}
40 | onSubmit={onSubmit}
41 | enableReinitialize={true} // because we change initial values whenever we switch organisation
42 | >
43 |
46 |
47 | )
48 | }
49 |
50 | export default SwitchOrganisationForm
51 |
--------------------------------------------------------------------------------
/backend/app/models/slack_message.py:
--------------------------------------------------------------------------------
1 | import enum
2 | import typing
3 |
4 | from sqlalchemy import Enum, ForeignKey, String, UnicodeText
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 |
7 | from app.db import Base
8 |
9 | from .mixins import SoftDeleteMixin, TimestampMixin
10 |
11 | if typing.TYPE_CHECKING:
12 | from .announcement import Announcement
13 | from .organisation import Organisation
14 |
15 |
16 | class SlackMessageKind(str, enum.Enum):
17 | CHANNEL_PINNED_POST = "CHANNEL_PINNED_POST"
18 | INCIDENT_UPDATE = "INCIDENT_UPDATE"
19 | ANNOUNCEMENT_POST = "ANNOUNCEMENT_POST"
20 |
21 |
22 | class SlackMessage(Base, TimestampMixin, SoftDeleteMixin):
23 | __prefix__ = "slk_msg"
24 |
25 | organisation_id: Mapped[str] = mapped_column(
26 | String(50), ForeignKey("organisation.id", ondelete="cascade"), nullable=False, index=True
27 | )
28 | announcement_id: Mapped[str] = mapped_column(
29 | String(50), ForeignKey("announcement.id", ondelete="cascade"), nullable=True, index=True
30 | )
31 | slack_message_ts: Mapped[str] = mapped_column(UnicodeText, nullable=False)
32 | slack_channel_id: Mapped[str] = mapped_column(UnicodeText, nullable=False)
33 | kind: Mapped[SlackMessageKind] = mapped_column(Enum(SlackMessageKind, native_enum=False), nullable=False)
34 |
35 | # relationships
36 | announcement: Mapped["Announcement"] = relationship("Announcement", back_populates="slack_messages")
37 | organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="slack_messages")
38 |
--------------------------------------------------------------------------------
/backend/tests/routes/test_api.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 |
3 | from app.main import app
4 | from app.schemas.actions import AuthUserSchema, CreateUserSchema
5 | from tests.factories import make_organisation, make_user
6 |
7 | client = TestClient(app)
8 |
9 |
10 | def test_create_register():
11 | data_in = CreateUserSchema(name="Test User", email_address="test@test.com", password="password")
12 |
13 | response = client.post(
14 | "/users/",
15 | json=data_in.model_dump(),
16 | )
17 |
18 | assert response.status_code == 200
19 |
20 |
21 | def test_login():
22 | organisation = make_organisation()
23 | make_user_result = make_user(organisation=organisation)
24 | auth_in = AuthUserSchema(
25 | email_address=make_user_result.user.email_address,
26 | password=make_user_result.password,
27 | )
28 |
29 | response = client.post(
30 | "/users/auth",
31 | json=auth_in.model_dump(),
32 | )
33 |
34 | assert response.status_code == 200
35 |
36 |
37 | def test_login_wrong_password():
38 | organisation = make_organisation()
39 | make_user_result = make_user(organisation=organisation)
40 | auth_in = AuthUserSchema(
41 | email_address=make_user_result.user.email_address,
42 | password="incorrect-password",
43 | )
44 |
45 | response = client.post(
46 | "/users/auth",
47 | json=auth_in.model_dump(),
48 | )
49 |
50 | assert response.status_code == 422
51 | assert response.json()["detail"] == "Could not login, please try again"
52 |
--------------------------------------------------------------------------------