set up /say endpoint

This commit is contained in:
Brent Schroeter 2025-01-31 14:30:08 -08:00
parent f7ca1c134b
commit d84041d6e3
17 changed files with 345 additions and 82 deletions

View file

@ -28,12 +28,16 @@ CREATE INDEX ON team_memberships (user_id);
CREATE TABLE api_keys ( CREATE TABLE api_keys (
id UUID NOT NULL PRIMARY KEY, id UUID NOT NULL PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id) team_id UUID NOT NULL REFERENCES teams(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
); );
CREATE INDEX ON api_Keys (team_id); CREATE INDEX ON api_Keys (team_id);
CREATE TABLE projects ( CREATE TABLE projects (
id UUID NOT NULL PRIMARY KEY, id UUID NOT NULL PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id), team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL name TEXT NOT NULL,
UNIQUE (team_id, name)
); );
CREATE INDEX ON projects(team_id);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS messages;

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY NOT NULL,
project_id UUID NOT NULL REFERENCES projects (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
message TEXT NOT NULL
);
CREATE INDEX ON messages (project_id);
CREATE INDEX ON messages (created_at);

View file

@ -1,30 +1,34 @@
use chrono::{DateTime, Utc};
use deadpool_diesel::postgres::Connection; use deadpool_diesel::postgres::Connection;
use diesel::prelude::*; use diesel::{
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
};
use uuid::Uuid; use uuid::Uuid;
use crate::{app_error::AppError, schema::api_keys::dsl::*, teams::Team}; use crate::{app_error::AppError, schema::api_keys, teams::Team};
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::api_keys)] #[diesel(table_name = api_keys)]
#[diesel(belongs_to(Team))] #[diesel(belongs_to(Team))]
pub struct ApiKey { pub struct ApiKey {
pub id: Uuid, pub id: Uuid,
pub team_id: Uuid, pub team_id: Uuid,
pub last_used_at: Option<DateTime<Utc>>,
} }
impl ApiKey { impl ApiKey {
pub async fn generate_for_team( pub async fn generate_for_team(db_conn: &Connection, team_id: Uuid) -> Result<Self, AppError> {
db_conn: &Connection,
key_team_id: Uuid,
) -> Result<Self, AppError> {
let api_key = Self { let api_key = Self {
team_id,
id: Uuid::new_v4(), id: Uuid::new_v4(),
team_id: key_team_id, last_used_at: None,
}; };
let api_key_copy = api_key.clone(); let api_key_copy = api_key.clone();
db_conn db_conn
.interact(move |conn| { .interact(move |conn| {
diesel::insert_into(api_keys) diesel::insert_into(api_keys::table)
.values(api_key_copy) .values(api_key_copy)
.execute(conn) .execute(conn)
}) })
@ -32,4 +36,20 @@ impl ApiKey {
.unwrap()?; .unwrap()?;
Ok(api_key) Ok(api_key)
} }
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<ApiKey, Pg> = ApiKey::as_select();
api_keys::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_id(id: Uuid) -> _ {
api_keys::id.eq(id)
}
#[auto_type(no_type_alias)]
pub fn with_team(team_id: Uuid) -> _ {
api_keys::team_id.eq(team_id)
}
} }

View file

@ -15,7 +15,7 @@ impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
Self::InternalServerError(err) => { Self::InternalServerError(err) => {
tracing::error!("Application error: {}", err); tracing::error!("Application error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response() (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
} }
Self::ForbiddenError(client_message) => { Self::ForbiddenError(client_message) => {

View file

@ -4,6 +4,7 @@ mod app_state;
mod auth; mod auth;
mod csrf; mod csrf;
mod guards; mod guards;
mod messages;
mod projects; mod projects;
mod router; mod router;
mod schema; mod schema;
@ -12,6 +13,7 @@ mod settings;
mod team_memberships; mod team_memberships;
mod teams; mod teams;
mod users; mod users;
mod v0_router;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;

42
src/messages.rs Normal file
View file

@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use diesel::{
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
};
use uuid::Uuid;
use crate::{projects::Project, schema::messages};
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = messages)]
#[diesel(belongs_to(Project))]
pub struct Message {
pub id: Uuid,
pub project_id: Uuid,
pub created_at: DateTime<Utc>,
pub message: String,
}
impl Message {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Message, Pg> = Message::as_select();
messages::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_project(project_id: Uuid) -> _ {
messages::project_id.eq(project_id)
}
#[auto_type(no_type_alias)]
pub fn values_now(project_id: Uuid, message: String) -> _ {
let id: Uuid = Uuid::now_v7();
(
messages::id.eq(id),
messages::project_id.eq(project_id),
messages::message.eq(message),
)
}
}

View file

@ -5,13 +5,10 @@ use diesel::{
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{schema::projects, teams::Team};
schema::{self, projects::dsl::*},
teams::Team,
};
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::projects)] #[diesel(table_name = projects)]
#[diesel(belongs_to(Team))] #[diesel(belongs_to(Team))]
pub struct Project { pub struct Project {
pub id: Uuid, pub id: Uuid,
@ -23,6 +20,16 @@ impl Project {
#[auto_type(no_type_alias)] #[auto_type(no_type_alias)]
pub fn all() -> _ { pub fn all() -> _ {
let select: AsSelect<Project, Pg> = Project::as_select(); let select: AsSelect<Project, Pg> = Project::as_select();
projects.select(select) projects::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_team(team_id: Uuid) -> _ {
projects::team_id.eq(team_id)
}
#[auto_type(no_type_alias)]
pub fn with_name(name: String) -> _ {
projects::name.eq(name)
} }
} }

View file

@ -1,3 +1,4 @@
use anyhow::Context;
use askama_axum::Template; use askama_axum::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
@ -28,6 +29,7 @@ use crate::{
team_memberships::TeamMembership, team_memberships::TeamMembership,
teams::Team, teams::Team,
users::{CurrentUser, User}, users::{CurrentUser, User},
v0_router,
}; };
pub fn new_router(state: AppState) -> Router<()> { pub fn new_router(state: AppState) -> Router<()> {
@ -36,6 +38,7 @@ pub fn new_router(state: AppState) -> Router<()> {
format!("{}", base_path).as_str(), format!("{}", base_path).as_str(),
Router::new() Router::new()
.route("/", get(landing_page)) .route("/", get(landing_page))
.nest("/v0", v0_router::new_router(state.clone()))
.route("/teams", get(teams_page)) .route("/teams", get(teams_page))
.route("/teams/{team_id}", get(team_page)) .route("/teams/{team_id}", get(team_page))
.route("/teams/{team_id}/projects", get(projects_page)) .route("/teams/{team_id}/projects", get(projects_page))
@ -64,17 +67,13 @@ async fn teams_page(
DbConn(conn): DbConn, DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let current_user_id = current_user.id.clone(); let team_memberships_query = current_user.clone().team_memberships();
let teams_of_current_user = conn let teams: Vec<Team> = conn
.interact(move |conn| { .interact(move |conn| team_memberships_query.load(conn))
schema::team_memberships::table
.inner_join(schema::teams::table)
.filter(schema::team_memberships::user_id.eq(current_user_id))
.select(Team::as_select())
.load(conn)
})
.await .await
.unwrap()?; .unwrap()
.context("failed to load team memberships")
.map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())?;
#[derive(Template)] #[derive(Template)]
#[template(path = "teams.html")] #[template(path = "teams.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -86,7 +85,7 @@ async fn teams_page(
ResponseTemplate { ResponseTemplate {
base_path, base_path,
current_user, current_user,
teams: teams_of_current_user, teams,
} }
.render()?, .render()?,
)) ))
@ -129,6 +128,7 @@ async fn new_team_page(
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
#[derive(Template)] #[derive(Template)]
#[template(path = "new-team.html")] #[template(path = "new-team.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -197,16 +197,14 @@ async fn projects_page(
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?; let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let team_id = team.id.clone(); let api_keys_query = team.clone().api_keys();
let api_keys = db_conn let (api_keys, projects) = db_conn
.interact(move |conn| { .interact(move |conn| {
schema::api_keys::table diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?))
.filter(schema::api_keys::team_id.eq(team_id))
.select(ApiKey::as_select())
.load(conn)
}) })
.await .await
.unwrap()?; .unwrap()?;
#[derive(Template)] #[derive(Template)]
#[template(path = "projects.html")] #[template(path = "projects.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -223,9 +221,9 @@ async fn projects_page(
base_path, base_path,
csrf_token, csrf_token,
current_user, current_user,
projects,
team, team,
keys: api_keys, keys: api_keys,
projects: vec![],
} }
.render()?, .render()?,
) )

View file

@ -4,6 +4,8 @@ diesel::table! {
api_keys (id) { api_keys (id) {
id -> Uuid, id -> Uuid,
team_id -> Uuid, team_id -> Uuid,
created_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
} }
} }
@ -24,6 +26,15 @@ diesel::table! {
} }
} }
diesel::table! {
messages (id) {
id -> Uuid,
project_id -> Uuid,
created_at -> Timestamptz,
message -> Text,
}
}
diesel::table! { diesel::table! {
projects (id) { projects (id) {
id -> Uuid, id -> Uuid,
@ -57,6 +68,7 @@ diesel::table! {
diesel::joinable!(api_keys -> teams (team_id)); diesel::joinable!(api_keys -> teams (team_id));
diesel::joinable!(csrf_tokens -> users (user_id)); diesel::joinable!(csrf_tokens -> users (user_id));
diesel::joinable!(messages -> projects (project_id));
diesel::joinable!(projects -> teams (team_id)); diesel::joinable!(projects -> teams (team_id));
diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> teams (team_id));
diesel::joinable!(team_memberships -> users (user_id)); diesel::joinable!(team_memberships -> users (user_id));
@ -65,6 +77,7 @@ diesel::allow_tables_to_appear_in_same_query!(
api_keys, api_keys,
browser_sessions, browser_sessions,
csrf_tokens, csrf_tokens,
messages,
projects, projects,
team_memberships, team_memberships,
teams, teams,

View file

@ -1,14 +1,17 @@
use diesel::{ use diesel::{
dsl::{AsSelect, Select}, dsl::{auto_type, AsSelect, Eq},
pg::Pg, pg::Pg,
prelude::*, prelude::*,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::schema::teams::dsl::*; use crate::{
api_keys::ApiKey,
schema::{api_keys, teams},
};
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::teams)] #[diesel(table_name = teams)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct Team { pub struct Team {
pub id: Uuid, pub id: Uuid,
@ -16,7 +19,17 @@ pub struct Team {
} }
impl Team { impl Team {
pub fn all() -> Select<teams, AsSelect<Team, Pg>> { #[auto_type(no_type_alias)]
teams.select(Team::as_select()) pub fn all() -> _ {
let select: AsSelect<Team, Pg> = Team::as_select();
teams::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn api_keys(self) -> _ {
let all: diesel::dsl::Select<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
let id: Uuid = self.id;
let filter: Eq<api_keys::team_id, Uuid> = ApiKey::with_team(id);
all.filter(filter)
} }
} }

View file

@ -1,3 +1,4 @@
use anyhow::Context;
use axum::{ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
http::request::Parts, http::request::Parts,
@ -7,17 +8,24 @@ use axum::{
use diesel::{ use diesel::{
associations::Identifiable, associations::Identifiable,
deserialize::Queryable, deserialize::Queryable,
dsl::{insert_into, AsSelect, Eq, Select}, dsl::{auto_type, insert_into, AsSelect, Eq, Select},
pg::Pg, pg::Pg,
prelude::*, prelude::*,
Selectable, Selectable,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{app_error::AppError, app_state::AppState, auth::AuthInfo, schema::users::dsl::*}; use crate::{
app_error::AppError,
app_state::AppState,
auth::AuthInfo,
schema::{team_memberships, teams, users},
team_memberships::TeamMembership,
teams::Team,
};
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::users)] #[diesel(table_name = users)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
@ -26,12 +34,24 @@ pub struct User {
} }
impl User { impl User {
pub fn all() -> Select<users, AsSelect<User, Pg>> { pub fn all() -> Select<users::table, AsSelect<User, Pg>> {
users.select(User::as_select()) users::table.select(User::as_select())
} }
pub fn with_uid(uid_value: &str) -> Eq<uid, &str> { pub fn with_uid(uid_value: &str) -> Eq<users::uid, &str> {
uid.eq(uid_value) users::uid.eq(uid_value)
}
#[auto_type(no_type_alias)]
pub fn team_memberships(self) -> _ {
let user_id: Uuid = self.id.clone();
let user_id_filter: Eq<team_memberships::user_id, Uuid> =
TeamMembership::with_user_id(user_id);
let select: AsSelect<(TeamMembership, Team), Pg> = <(TeamMembership, Team)>::as_select();
team_memberships::table
.inner_join(teams::table)
.filter(user_id_filter)
.select(select)
} }
} }
@ -58,21 +78,37 @@ impl FromRequestParts<AppState> for CurrentUser {
let maybe_current_user = User::all() let maybe_current_user = User::all()
.filter(User::with_uid(&auth_info.sub)) .filter(User::with_uid(&auth_info.sub))
.first(conn) .first(conn)
.optional()?; .optional()
.context("failed to load maybe_current_user")?;
if let Some(current_user) = maybe_current_user { if let Some(current_user) = maybe_current_user {
return Ok(current_user); return Ok(current_user);
} }
let new_user = User { let new_user = User {
id: Uuid::now_v7(), id: Uuid::now_v7(),
uid: auth_info.sub, uid: auth_info.sub.clone(),
email: auth_info.email, email: auth_info.email,
}; };
insert_into(users) match insert_into(users::table)
.values(new_user) .values(new_user)
.on_conflict(uid) .on_conflict(users::uid)
.do_nothing() .do_nothing()
.returning(User::as_returning()) .returning(User::as_returning())
.get_result(conn) .get_result(conn)
{
QueryResult::Err(diesel::result::Error::NotFound) => {
tracing::debug!("detected race to insert current user record");
User::all()
.filter(User::with_uid(&auth_info.sub))
.first(conn)
.context(
"failed to load record after detecting race to insert current user",
)
}
QueryResult::Err(err) => {
Err(err).context("failed to insert current user record")
}
QueryResult::Ok(result) => Ok(result),
}
}) })
.await .await
.unwrap() .unwrap()

89
src/v0_router.rs Normal file
View file

@ -0,0 +1,89 @@
use anyhow::Context;
use axum::{
extract::Query,
response::{IntoResponse, Json},
routing::get,
Router,
};
use diesel::{dsl::insert_into, prelude::*, update};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
api_keys::ApiKey,
app_error::AppError,
app_state::{AppState, DbConn},
messages::Message,
projects::Project,
schema::{api_keys, messages, projects},
};
pub fn new_router(state: AppState) -> Router<AppState> {
Router::new().route("/say", get(say_get)).with_state(state)
}
#[derive(Deserialize)]
struct SayQuery {
key: Uuid,
project: String,
message: String,
}
async fn say_get(
DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> {
let key = query.key.clone();
let maybe_api_key = db_conn
.interact(move |conn| {
update(api_keys::table.filter(ApiKey::with_id(key)))
.set(api_keys::last_used_at.eq(diesel::dsl::now))
.returning(ApiKey::as_returning())
.get_result(conn)
.optional()
})
.await
.unwrap()
.context("unable to get API key")?;
let api_key = match maybe_api_key {
Some(api_key) => api_key,
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
};
let project_name = query.project.to_lowercase();
let project = db_conn
.interact(move |conn| {
insert_into(projects::table)
.values((
projects::id.eq(Uuid::now_v7()),
projects::team_id.eq(api_key.team_id.clone()),
projects::name.eq(project_name.clone()),
))
.on_conflict((projects::team_id, projects::name))
.do_nothing()
.execute(conn)?;
// It would be nice to merge these two database operations into one,
// but it's not trivial to do so without faking an update; refer to:
// https://stackoverflow.com/a/42217872
Project::all()
.filter(Project::with_team(api_key.team_id))
.filter(Project::with_name(project_name))
.first(conn)
})
.await
.unwrap()
.context("unable to get project")?;
db_conn
.interact(move |conn| {
insert_into(messages::table)
.values(Message::values_now(project.id, query.message))
.execute(conn)
})
.await
.unwrap()
.context("unable to insert message")?;
#[derive(Serialize)]
struct ResponseBody {
ok: bool,
}
Ok(Json(ResponseBody { ok: true }))
}

View file

@ -20,6 +20,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#">Projects</a> <a class="nav-link" href="#">Projects</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="#">Channels</a>
</li>
</ul> </ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0 profile-menu"> <ul class="navbar-nav ms-auto mb-2 mb-lg-0 profile-menu">
<li class="nav-item dropdown"> <li class="nav-item dropdown">

View file

@ -19,7 +19,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-primary" type="submit">Submit</button> <button class="btn btn-primary" type="submit">Submit</button>
<a class="btn btn-outline-light" role="button" href="{{ base_path }}/teams"> <a class="btn btn-secondary" role="button" href="{{ base_path }}/teams">
Cancel Cancel
</a> </a>
</div> </div>

View file

@ -35,11 +35,16 @@
</section> </section>
<section class="mb-3"> <section class="mb-3">
<table class="table"> <table class="table">
<thead>
<tr>
<th>Project Name</th>
</tr>
</thead>
<tbody> <tbody>
{% for project in projects %} {% for project in projects %}
<tr> <tr>
<td> <td>
<a href="{{ base_path }}/projects/{{ project.id }}"> <a href="{{ base_path }}/teams/{{ team.id.simple() }}/projects/{{ project.id.simple() }}">
{{ project.name }} {{ project.name }}
</a> </a>
</td> </td>
@ -64,10 +69,10 @@
<thead> <thead>
<tr> <tr>
<th> <th>
API key API Key
</th> </th>
<th> <th title="Last Used (UTC)">
Last used Last Used
</th> </th>
<th> <th>
Actions Actions
@ -83,11 +88,22 @@
</code> </code>
</td> </td>
<td> <td>
Unknown {% if let Some(last_used_at) = key.last_used_at %}
{{ last_used_at.format("%Y-%m-%d") }}
{% else %}
Never
{% endif %}
</td> </td>
<td> <td>
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions"> <div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
<button class="btn btn-outline-light" type="button">Copy</button> <button
class="btn btn-outline-light"
type="button"
name="api-key-copy-button"
data-copy="{{ key.id.simple() }}"
>
Copy
</button>
<button class="btn btn-outline-light" type="button">Delete</button> <button class="btn btn-outline-light" type="button">Delete</button>
</div> </div>
</td> </td>
@ -100,4 +116,15 @@
</div> </div>
</div> </div>
</main> </main>
<script>
document.addEventListener("DOMContentLoaded", function() {
document.getElementsByName("api-key-copy-button")
.forEach(function (btn) {
btn.addEventListener("click", function (ev) {
var content = ev.currentTarget.getAttribute("data-copy");
navigator.clipboard.writeText(content);
});
});
});
</script>
{% endblock %} {% endblock %}

View file

@ -8,20 +8,21 @@
<li class="breadcrumb-item active" aria-current="page">Teams</li> <li class="breadcrumb-item active" aria-current="page">Teams</li>
</ol> </ol>
</nav> </nav>
<main class="mt-5"> <main class="container mt-5">
<section class="container mb-3"> <section class="mb-4">
<h1 class="mb-4">Teams</h1> <div class="d-flex justify-content-between align-items-center">
<h1>Teams</h1>
<div>
<a href="{{ base_path }}/new-team" role="button" class="btn btn-primary">New Team</a>
</div>
</div>
</section> </section>
{% if teams.len() == 0 %} {% if teams.len() == 0 %}
<div class="container">
<div class="alert alert-primary" role="alert"> <div class="alert alert-primary" role="alert">
Doesn't look like you've created or been invited to any teams yet. Doesn't look like you've created or been invited to any teams yet.
<a href="{{ base_path }}/new-team">Click here</a> to create one.
</div>
</div> </div>
{% endif %} {% endif %}
<section class="mb-3"> <section class="mb-3">
<div class="container">
<table class="table"> <table class="table">
<tbody> <tbody>
{% for team in teams %} {% for team in teams %}
@ -35,7 +36,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
</main> </main>
{% endblock %} {% endblock %}