From ed836167dd1868148f92fb62771842d356219698 Mon Sep 17 00:00:00 2001 From: updraft0 <121029559+updraft0@users.noreply.github.com> Date: Sat, 9 Nov 2024 23:04:23 +0000 Subject: [PATCH] feat(auth,landing): mark characters with likely expired auth tokens on landing page and cleanup the layout a little --- .../updraft0/controltower/protocol/user.scala | 3 +- .../controltower/server/auth/MapPolicy.scala | 2 +- .../controltower/server/db/AuthQueries.scala | 21 +-- .../controltower/server/endpoints/user.scala | 11 +- .../controltower/server/map/MapSession.scala | 9 +- ui/src/main/css/views/landing-page.css | 173 +++++++++++++----- ui/src/main/css/views/nav-top-view.css | 6 +- .../scala/controltower/page/landing.scala | 100 +++++----- 8 files changed, 208 insertions(+), 117 deletions(-) diff --git a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/user.scala b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/user.scala index 558ac59..86b4e11 100644 --- a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/user.scala +++ b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/user.scala @@ -9,7 +9,8 @@ case class UserCharacter( name: String, characterId: CharacterId, corporationId: CorporationId, - allianceId: Option[AllianceId] + allianceId: Option[AllianceId], + authTokenFresh: Boolean ) case class UserCharacterMap(characterId: CharacterId, mapId: MapId, mapName: String, mapRole: MapRole) diff --git a/server/src/main/scala/org/updraft0/controltower/server/auth/MapPolicy.scala b/server/src/main/scala/org/updraft0/controltower/server/auth/MapPolicy.scala index 0a93971..f190929 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/auth/MapPolicy.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/auth/MapPolicy.scala @@ -47,7 +47,7 @@ object MapPolicy: def allowedMapIdsForUser(userId: UserId): RIO[Env, List[(CharacterId, MapId, model.MapRole)]] = AuthQueries.getUserCharactersById(userId).flatMap { case None => ZIO.succeed(List.empty[(CharacterId, MapId, model.MapRole)]) - case Some((_, chars)) => + case Some((_, chars, _)) => allowedMapIdsForCharacters(chars.map(_.id)).map(_.toList.flatMap((k, v) => v.map(mp => (k, mp._1, mp._2)))) } diff --git a/server/src/main/scala/org/updraft0/controltower/server/db/AuthQueries.scala b/server/src/main/scala/org/updraft0/controltower/server/db/AuthQueries.scala index cc2d8fd..21ec82f 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/db/AuthQueries.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/db/AuthQueries.scala @@ -10,8 +10,6 @@ import zio.* import javax.sql.DataSource import java.util.UUID -case class CharacterMapRole(characterId: CharacterId, mapId: MapId, role: model.MapRole) - /** Queries for user/auth related operations */ object AuthQueries: @@ -19,6 +17,9 @@ object AuthQueries: import auth.given import auth.schema.* + @scala.annotation.unused // ?? + private inline val ThirtyDaysInSeconds = 30 * 24 * 60 + // note: this ignores session expiry private inline def getSessionById(sessionId: UUID) = userSession.filter(_.sessionId == lift(sessionId)) @@ -29,24 +30,22 @@ object AuthQueries: def getCharacterByName(name: String): Result[Option[model.AuthCharacter]] = run(quote(character.filter(_.name == lift(name)))).map(_.headOption) - def getUserCharacterByIdAndCharId( - userId: UserId, - characterId: CharacterId - ): Result[Option[(model.AuthUser, model.AuthCharacter)]] = - getUserCharactersById(userId).map(_.flatMap((u, chars) => chars.find(_.id == characterId).map(c => (u -> c)))) - def getUserCharactersById( userId: UserId - ): Result[Option[(model.AuthUser, List[model.AuthCharacter])]] = + ): Result[Option[(model.AuthUser, List[model.AuthCharacter], List[CharacterId])]] = run(quote { for user <- user.filter(_.id == lift(userId)) userCharacters <- userCharacter.join(_.userId == user.id) characters <- character.join(_.id == userCharacters.characterId) - yield (user, characters) + authTokenCharacters <- characterAuthToken + .filter(_.updatedAt.exists(_ > unixepochMinusSeconds(ThirtyDaysInSeconds))) + .leftJoin(_.characterId == userCharacters.characterId) + .map(_.map(_.characterId)) + yield (user, characters, authTokenCharacters) }).map { case Nil => None - case xs => Some(xs.head._1, xs.map(_._2)) + case xs => Some(xs.head._1, xs.map(_._2), xs.flatMap(_._3)) } def getUserCharactersBySessionId( diff --git a/server/src/main/scala/org/updraft0/controltower/server/endpoints/user.scala b/server/src/main/scala/org/updraft0/controltower/server/endpoints/user.scala index 4507bcb..87665f2 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/endpoints/user.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/endpoints/user.scala @@ -55,11 +55,14 @@ def getUserInfo = Endpoints.getUserInfo .orElseFail("Failure while trying to find user") .flatMap { case None => ZIO.fail("No user found") - case Some(user, characters) => + case Some(user, characters, withAuthTokens) => MapPolicy .getMapsForCharacters(characters.map(_.id)) .tapErrorCause(ZIO.logWarningCause("failed map policy lookup", _)) - .mapBoth(_ => "Failure while trying to find map policies", toUserInfo(user, characters, _)) + .mapBoth( + _ => "Failure while trying to find map policies", + toUserInfo(user, characters, withAuthTokens, _) + ) } ) @@ -87,6 +90,7 @@ def allUserEndpoints: List[ZServerEndpoint[EndpointEnv, Any]] = private def toUserInfo( user: model.AuthUser, characters: List[model.AuthCharacter], + withAuthTokens: List[CharacterId], mapsPerCharacter: Map[CharacterId, List[(model.MapModel, model.MapRole)]] ): protocol.UserInfo = protocol.UserInfo( @@ -97,7 +101,8 @@ private def toUserInfo( name = ac.name, characterId = ac.id, corporationId = ac.corporationId, - allianceId = ac.allianceId + allianceId = ac.allianceId, + authTokenFresh = withAuthTokens.contains(ac.id) ) }, maps = toUserCharacterMaps(mapsPerCharacter) diff --git a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala index 8ca1e47..0835aa2 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala @@ -187,10 +187,10 @@ object MapSession: ctx.ourQ.offer( protocol.MapMessage .MapMeta( - toProtoCharacter(ctx.character), + toProtoCharacter(ctx.character, authTokenFresh = true /* lie through our teeth */ ), toMapInfo(map), toProtocolRole(role), - protocol.UserPreferences.Default + protocol.UserPreferences.Default /* TODO load preferences */ ) ) case _ => ZIO.logError("BUG: map not set") @@ -346,12 +346,13 @@ private def toProto(msg: MapResponse): Option[protocol.MapMessage] = msg match ) ) -private def toProtoCharacter(char: model.AuthCharacter) = +private def toProtoCharacter(char: model.AuthCharacter, authTokenFresh: Boolean) = protocol.UserCharacter( name = char.name, characterId = char.id, corporationId = char.corporationId, - allianceId = char.allianceId + allianceId = char.allianceId, + authTokenFresh = authTokenFresh ) private def toAddSystem(msg: protocol.MapRequest.AddSystem) = diff --git a/ui/src/main/css/views/landing-page.css b/ui/src/main/css/views/landing-page.css index 74aaebf..17ceefe 100644 --- a/ui/src/main/css/views/landing-page.css +++ b/ui/src/main/css/views/landing-page.css @@ -1,19 +1,66 @@ .landing-page { + $character-image-width: 132px; /* 128 + 4px margin */ - & #map-selection { - margin-left: 6em; - margin-right: 6em; - display: grid; - grid-auto-columns: max-content; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr; /* TODO hmnn is this really better? */ + & a.login-to-eve { + display: block; + text-align: center; } - & table { - display: inline-block; - border-collapse: collapse; + #map-selection { + display: flex; + flex-wrap: wrap; + align-items: stretch; } - & td.character { + & div.topnav { + height: 1.4em; + margin-bottom: 4px; + user-select: none; + + /* darker look */ + border: 1px solid $gray-darkest; + background-color: $gray-darkest; + border-radius: 2px; + + display: flex; + + padding: 0 0 0 5px; + + & > * { + text-decoration: none; + align-self: center; + margin-left: 2px; + color: $gray-light; + } + + /* nav buttons */ + & button.navitem { + background-color: inherit; + color: $gray-lighter; + border: none; + border-radius: 2px; + font-size: 1.1em; + height: 1.2em; + width: 1.2em; + + &:hover:not(:disabled) { + background: $teal-dark; + color: $orange; + } + + &:disabled { + color: $gray; + } + } + } + + & div.character-and-maps { + margin: 2em 3em; + + flex: 0 0 calc($character-image-width * 2.5); + min-height: $character-image-width; + + display: inline-flex; & img.character-image { display: block; @@ -22,68 +69,96 @@ border-top-right-radius: 7px; } - & div { + & .expired-auth { + & img.character-image, & div.character-name { + border-color: $red-dark; + } + } + + & div.character-name { + max-width: $character-image-width; background: $gray-darker; color: $gray-lighter; - display: block; - border-bottom: 2px solid $gray; - border-left: 2px solid $gray; - border-right: 2px solid $gray; - height: 100%; - width: 100%; - + border: 2px solid $gray; + border-top: none; - & button.logout { + & button { display: inline-block; border: none; - color: $red; font-size: 1em; - margin-left: 2px; + &.logout { + color: $red; + &:hover { + color: $orange; + } + } + } + + & a.login { + margin-left: 2px; + display: inline-block; + border: none; + font-size: 1em; + color: $green-dark; + text-decoration: none; &:hover { - color: $orange; + color: $green; } } - & mark { + & mark.name { margin-left: 2px; background-color: inherit; color: $gray-lighter; text-align: center; - user-select: none; } } - - } - & td.map-link { - & a, & button.new-map { - display: block; - height: 2em; - min-width: 6em; - max-width: 12em; - text-align: center; - border-radius: 4px; - text-decoration: none; - font-weight: bold; - font-size: 0.9em; - padding-left: 2px; - padding-right: 2px; - - border: 2px solid $gray-darker; - color: $gray-lighter; - background: $teal-darkest; + & div.maps { + display: inline-flex; + flex-direction: column; + flex-wrap: wrap; + align-items: stretch; + justify-content: center; + margin-left: 1em; + + & div.map-link { + margin-bottom: 0.3em; + + & a, & button.new-map { + display: block; + min-height: 2em; + min-width: 6em; + text-align: left; + border-radius: 4px; + text-decoration: none; + font-weight: bold; + font-size: 0.9em; + padding-left: 2px; + padding-right: 2px; + + border: 2px solid $gray-darker; + color: $gray-lighter; + background: $teal-darkest; - &:hover { - border: 2px solid $orange; + &:hover { + border: 2px solid $green-dark; + } } - } - & button.new-map { - background: $green-dark; - color: $gray-lightest; + & button.new-map { + background: $green-dark; + color: $gray-lightest; + width: 100%; + + &:hover { + border-color: $gray-lightest; + } + } } } + } \ No newline at end of file diff --git a/ui/src/main/css/views/nav-top-view.css b/ui/src/main/css/views/nav-top-view.css index 65bd6ec..44eed3b 100644 --- a/ui/src/main/css/views/nav-top-view.css +++ b/ui/src/main/css/views/nav-top-view.css @@ -9,7 +9,7 @@ div#nav-top-view { display: flex; - padding: 0px 0px 0px 5px; + padding: 0 0 0 5px; & > * { align-self: center; @@ -27,8 +27,8 @@ div#nav-top-view { border: none; border-radius: 2px; font-size: 1.1em; - height: 1.3em; - width: 1em; + height: 1.2em; + width: 1.2em; &:hover:not(:disabled) { background: $teal-dark; diff --git a/ui/src/main/scala/controltower/page/landing.scala b/ui/src/main/scala/controltower/page/landing.scala index 0853100..bb535da 100644 --- a/ui/src/main/scala/controltower/page/landing.scala +++ b/ui/src/main/scala/controltower/page/landing.scala @@ -25,12 +25,11 @@ object LandingPage: } div( cls := "landing-page", - "Control Tower Landing page", + topNav, child <-- userInfo.signal.map( _.map(renderCharacterMapSelection).getOrElse(emptyNode) ), - renderLogin(ct.loginUrl), - a(Routes.navigateTo(Page.MapEditor), "Map Editor") + renderLogin(ct.loginUrl) ) private def renderCharacterMapSelection(userInfo: UserInfo)(using ControlTowerBackend) = @@ -46,56 +45,59 @@ object LandingPage: ) private def renderCharacterMaps(char: UserCharacter, maps: List[UserCharacterMap])(using ControlTowerBackend) = - maps match - case Nil => - table( - cls := "character-maps", - List( - tr( - td(cls := "character", renderCharacter(char)), - td(cls := "map-link", newMapLink(char)) - ) - ) - ) - case map :: xs => - table( - cls := "character-maps", - tr( - td(cls := "character", rowSpan := maps.length + 1, renderCharacter(char)), - td(cls := "map-link", mapLink(char, map)) - ) :: xs.map(map => tr(td(cls := "map-link", mapLink(char, map)))) ::: - List(tr(td(cls := "map-link", newMapLink(char)))) - ) - - private def renderLogin(login: Uri) = div( - a( - href := login.toString, - img(src := LoginWithEveImageUrlLarge, alt := "Login to EVE Online") + cls := "character-and-maps", + renderCharacter(char), + div( + cls := "maps", + nodeSeq(maps.map(map => div(cls := "map-link", mapLink(char, map)))*), + div(cls := "map-link", newMapLink(char)) ) ) + private def renderLogin(login: Uri) = + a( + cls := "login-to-eve", + href := login.toString, + img(src := LoginWithEveImageUrlLarge, alt := "Login to EVE Online") + ) + private def renderCharacter(char: UserCharacter)(using ct: ControlTowerBackend) = - import com.raquo.laminar.api.features.unitArrows - nodeSeq( + div( + cls := "character", + cls("expired-auth") := !char.authTokenFresh, ESI.characterImage(char.characterId, char.name), div( - button( - tpe := "button", - cls := "logout", - cls := "ti", - cls := "ti-logout", - onClick.preventDefault --> Modal.showConfirmation( - s"Logout ${char.name}?", - s"Are you sure you want to logout ${char.name} from the map", - onOk = Observer(_ => - ct.logoutUserCharacter(char.characterId) - .onComplete(_ => Routes.router.pushState(Page.Landing)) - ), - isDestructive = true - ) + cls := "character-name", + if (!char.authTokenFresh) buttonRefreshLogin else emptyNode, + buttonLogout(char), + mark(cls := "name", char.name) + ) + ) + + private def buttonRefreshLogin(using ct: ControlTowerBackend) = + a( + cls := "login", + cls := "ti", + cls := "ti-refresh-alert", + href := ct.loginUrl.toString + ) + + private def buttonLogout(char: UserCharacter)(using ct: ControlTowerBackend) = + import com.raquo.laminar.api.features.unitArrows + button( + tpe("button"), + cls := "logout", + cls := "ti", + cls := "ti-logout", + onClick.preventDefault --> Modal.showConfirmation( + s"Logout ${char.name}?", + s"Are you sure you want to logout '${char.name}' from the map", + onOk = Observer(_ => + ct.logoutUserCharacter(char.characterId) + .onComplete(_ => Routes.router.pushState(Page.Landing)) ), - mark(char.name) + isDestructive = true ) ) @@ -103,6 +105,7 @@ object LandingPage: button( cls := "new-map", tpe("button"), + i(cls := "ti", cls := "ti-plus"), "New map", onClick.preventDefault --> { _ => Modal.show( @@ -122,3 +125,10 @@ object LandingPage: " ", map.mapName ) + + private def topNav = + div( + cls := "topnav", + span("ControlTower"), + button(cls := "navitem", cls := "ti", cls := "ti-map-cog", Routes.navigateTo(Page.MapEditor)) + )