diff --git a/.gitignore b/.gitignore index 0741664..af54818 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ TODO.md result .envrc elm-stuff/ +serve.sh diff --git a/assets/js/app.js b/assets/js/app.js index 8341225..89360f5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4,6 +4,7 @@ import "../css/app.css"; import '@fortawesome/fontawesome-free/js/fontawesome'; import '@fortawesome/fontawesome-free/js/solid'; +import '@fortawesome/fontawesome-free/js/brands'; import './jspdf.min.js'; import './svg2pdf.js'; @@ -32,7 +33,7 @@ import { Elm } from "../src/Main.elm"; const initElm = () => { var app = Elm.Main.init({ flags: { - csrfToken: csrfToken, + csrfToken: csrfToken(), } }); diff --git a/assets/package-lock.json b/assets/package-lock.json index e5e275b..0ac3487 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -1067,9 +1067,9 @@ "dev": true }, "acorn": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", - "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==", "dev": true }, "aggregate-error": { diff --git a/assets/src/Api.elm b/assets/src/Api.elm new file mode 100644 index 0000000..42cef3c --- /dev/null +++ b/assets/src/Api.elm @@ -0,0 +1,17 @@ +module Api exposing (getUser) + +import Http +import Viewer exposing (Viewer) + + +getUser : (Result Http.Error Viewer -> msg) -> Cmd msg +getUser receivedViewer = + Http.request + { method = "GET" + , headers = [] + , url = "/api/user" + , body = Http.emptyBody + , expect = Http.expectJson receivedViewer Viewer.decoder + , timeout = Nothing + , tracker = Nothing + } diff --git a/assets/src/Auth.elm b/assets/src/Auth.elm new file mode 100644 index 0000000..5171dc9 --- /dev/null +++ b/assets/src/Auth.elm @@ -0,0 +1,17 @@ +module Auth exposing (logout) + +import Http +import Session exposing (Session) + + +logout : Session -> (Result Http.Error () -> msg) -> Cmd msg +logout session receivedLogout = + Http.request + { method = "DELETE" + , headers = [ Http.header "x-csrf-token" (Session.csrfToken session) ] + , url = "/auth/logout" + , body = Http.emptyBody + , expect = Http.expectWhatever receivedLogout + , timeout = Nothing + , tracker = Nothing + } diff --git a/assets/src/Github.elm b/assets/src/Github.elm index 128702b..f6ba4f8 100644 --- a/assets/src/Github.elm +++ b/assets/src/Github.elm @@ -30,7 +30,6 @@ module Github exposing -} import Base64 -import Browser.Navigation import Http import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline as Decode diff --git a/assets/src/LocalStorage.elm b/assets/src/LocalStorage.elm index da95607..ae5d217 100644 --- a/assets/src/LocalStorage.elm +++ b/assets/src/LocalStorage.elm @@ -28,7 +28,6 @@ import Length exposing (Meters) import Pattern exposing (Pattern) import Point2d exposing (Point2d) import Storage.Address as Address exposing (Address) -import Url.Parser exposing ((), Parser, map, oneOf, s, string, top) port storeCache : { key : String, value : String } -> Cmd msg diff --git a/assets/src/Login.elm b/assets/src/Login.elm index 30ce16b..7350912 100644 --- a/assets/src/Login.elm +++ b/assets/src/Login.elm @@ -1,4 +1,4 @@ -module Main exposing (main) +module Login exposing (main) import Browser import Browser.Navigation diff --git a/assets/src/Main.elm b/assets/src/Main.elm index 6ea7061..83b7b9f 100644 --- a/assets/src/Main.elm +++ b/assets/src/Main.elm @@ -18,30 +18,32 @@ module Main exposing (main) along with this program. If not, see . -} +import Api import Browser import Browser.Dom import Browser.Events import Browser.Navigation import Element exposing (Element) import Element.Font as Font -import Github import Html exposing (Html) import Http -import Json.Decode as Decode +import Json.Decode as Decode exposing (Decoder, Value) +import Json.Decode.Pipeline as Decode import Page.Details as Details import Page.Pattern as Pattern import Page.PatternNew as PatternNew import Page.Patterns as Patterns +import Page.Root as Root import Pattern exposing (Pattern) -import RemoteData exposing (WebData) import Route exposing (Route) import Session exposing (Session) import Task +import Ui.Theme.Spacing import Url exposing (Url) -import Url.Builder +import Viewer exposing (Viewer) -main : Program {} Model Msg +main : Program Value Model Msg main = Browser.application { init = init @@ -58,21 +60,17 @@ main = type Model - = Loading LoadingData + = Error String + | Loading LoadingData | Loaded LoadedData -type alias RequestingClientIdData = +type alias LoadingData = { key : Browser.Navigation.Key - , domain : String , url : Url - } - - -type alias LoadingData = - { session : Session - , maybeRoute : Maybe Route + , csrfToken : String , maybeDevice : Maybe Element.Device + , maybeViewer : Maybe (Maybe Viewer) } @@ -89,6 +87,9 @@ toSession page = session -- PAGES + Root root -> + Root.toSession root + Patterns patterns -> Patterns.toSession patterns @@ -109,53 +110,43 @@ toSession page = type Page = NotFound Session -- PAGES + | Root Root.Model | Patterns Patterns.Model | PatternNew PatternNew.Model | Pattern Pattern.Model | Details Details.Model -init : {} -> Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) -init _ url key = - let - session = - Session.anonymous key domain - - domain = - String.concat - [ case url.protocol of - Url.Http -> - "http" - - Url.Https -> - "https" - , "://" - , url.host - , case url.port_ of - Nothing -> - "" - - Just port_ -> - ":" ++ String.fromInt port_ - ] - in - case Route.fromUrl url of - Nothing -> - ( Loading - { session = session - , maybeRoute = Nothing - , maybeDevice = Nothing - } - , getViewport +type alias Flags = + { csrfToken : String } + + +flagsDecoder : Decoder Flags +flagsDecoder = + Decode.succeed Flags + |> Decode.required "csrfToken" Decode.string + + +init : Value -> Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) +init rawFlags url key = + case Decode.decodeValue flagsDecoder rawFlags of + Err decodeError -> + ( Error (Decode.errorToString decodeError) + , Cmd.none ) - Just route -> + Ok flags -> ( Loading - { session = session - , maybeRoute = Just route + { key = key + , url = url + , csrfToken = flags.csrfToken , maybeDevice = Nothing + , maybeViewer = Nothing } - , getViewport + , Cmd.batch + [ getViewport + , Api.getUser ReceivedViewer + ] ) @@ -166,6 +157,28 @@ init _ url key = view : Model -> Browser.Document Msg view model = case model of + Error error -> + { title = "Error" + , body = + [ viewHelp <| + Element.column + [ Element.centerX + , Element.centerY + , Element.width (Element.px 640) + , Element.spacing Ui.Theme.Spacing.level4 + ] + [ Element.text "Something went very wrong:" + , Element.el + [ Font.family + [ Font.monospace + ] + , Font.size 14 + ] + (Element.text error) + ] + ] + } + Loading _ -> { title = "Initializing..." , body = @@ -186,6 +199,15 @@ view model = ] } + Root rootModel -> + let + { title, body } = + Root.view rootModel + in + { title = title + , body = [ viewHelp (Element.map RootMsg body) ] + } + Patterns patternsModel -> let { title, body } = @@ -253,7 +275,9 @@ type Msg | UrlChanged Url -- LOADING | ChangedDevice Element.Device + | ReceivedViewer (Result Http.Error Viewer) -- PAGES + | RootMsg Root.Msg | PatternsMsg Patterns.Msg | PatternNewMsg PatternNew.Msg | PatternMsg Pattern.Msg @@ -263,6 +287,9 @@ type Msg update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case model of + Error _ -> + ( model, Cmd.none ) + Loading data -> updateLoading msg data @@ -294,15 +321,25 @@ updateLoading msg data = { data | maybeDevice = Just device } |> checkLoaded + ReceivedViewer result -> + case result of + Err _ -> + { data | maybeViewer = Just Nothing } + |> checkLoaded + + Ok viewer -> + { data | maybeViewer = Just (Just viewer) } + |> checkLoaded + _ -> ( Loading data, Cmd.none ) checkLoaded : LoadingData -> ( Model, Cmd Msg ) checkLoaded data = - case data.maybeDevice of - Just device -> - initLoaded data device + case ( data.maybeDevice, data.maybeViewer ) of + ( Just device, Just viewer ) -> + initLoaded data device viewer _ -> ( Loading data @@ -310,32 +347,31 @@ checkLoaded data = ) -initLoaded : LoadingData -> Element.Device -> ( Model, Cmd Msg ) -initLoaded data device = +initLoaded : LoadingData -> Element.Device -> Maybe Viewer -> ( Model, Cmd Msg ) +initLoaded data device viewer = let ( page, cmd ) = - case data.maybeRoute of + case Route.fromUrl data.url of Nothing -> - ( NotFound data.session + ( NotFound session , Cmd.none ) Just route -> - changePageTo data.session route + changePageTo session route + + session = + Session.fromViewer + { key = data.key + , csrfToken = data.csrfToken + } + viewer in ( Loaded { device = device , page = page } - , Cmd.batch - [ cmd - , case data.maybeRoute of - Nothing -> - Cmd.none - - Just route -> - Route.replaceUrl (Session.navKey data.session) route - ] + , cmd ) @@ -349,12 +385,8 @@ updateLoaded msg data = ( UrlRequested urlRequest, _ ) -> case urlRequest of Browser.Internal url -> - let - key = - Session.navKey (toSession data.page) - in ( Loaded data - , Browser.Navigation.pushUrl key url.path + , Browser.Navigation.pushUrl (Session.key (toSession data.page)) url.path ) Browser.External externalUrl -> @@ -391,10 +423,25 @@ updateLoaded msg data = else ( Loaded data, Cmd.none ) + ( ReceivedViewer _, _ ) -> + ( Loaded data, Cmd.none ) + -- PAGES ( _, NotFound _ ) -> ( Loaded data, Cmd.none ) + ( RootMsg rootMsg, Root rootModel ) -> + let + ( newRootModel, rootCmd ) = + Root.update rootMsg rootModel + in + ( Loaded { data | page = Root newRootModel } + , Cmd.map RootMsg rootCmd + ) + + ( RootMsg _, _ ) -> + ( Loaded data, Cmd.none ) + ( PatternsMsg patternsMsg, Patterns patternsModel ) -> let ( newPatternsModel, patternsCmd ) = @@ -451,14 +498,25 @@ updateLoaded msg data = changePageTo : Session -> Route -> ( Page, Cmd Msg ) changePageTo session route = case route of - Route.Patterns -> - let - ( patterns, patternsCmd ) = - Patterns.init session - in - ( Patterns patterns - , Cmd.map PatternsMsg patternsCmd - ) + Route.Root -> + case Session.viewer session of + Nothing -> + let + ( root, rootCmd ) = + Root.init session + in + ( Root root + , Cmd.map RootMsg rootCmd + ) + + Just _ -> + let + ( patterns, patternsCmd ) = + Patterns.init session + in + ( Patterns patterns + , Cmd.map PatternsMsg patternsCmd + ) Route.Pattern address -> let @@ -495,6 +553,9 @@ changePageTo session route = subscriptions : Model -> Sub Msg subscriptions model = case model of + Error _ -> + Sub.none + Loading _ -> Browser.Events.onResize <| \width height -> @@ -518,6 +579,9 @@ subscriptions model = Sub.none -- PAGES + Root rootModel -> + Sub.map RootMsg (Root.subscriptions rootModel) + Patterns patternsModel -> Sub.map PatternsMsg (Patterns.subscriptions patternsModel) diff --git a/assets/src/Page/Details.elm b/assets/src/Page/Details.elm index 3c14e77..71d22c5 100644 --- a/assets/src/Page/Details.elm +++ b/assets/src/Page/Details.elm @@ -32,10 +32,8 @@ module Page.Details exposing import Angle import Axis3d -import BoundingBox2d import Browser.Dom import Browser.Events -import Browser.Navigation import Camera3d import Detail2d exposing (Detail2d) import Detail3d diff --git a/assets/src/Page/PatternNew.elm b/assets/src/Page/PatternNew.elm index 5d38d4c..f1c4fbd 100644 --- a/assets/src/Page/PatternNew.elm +++ b/assets/src/Page/PatternNew.elm @@ -12,6 +12,7 @@ module Page.PatternNew exposing -} +import Auth import Browser.Navigation import Element exposing (Element) import Element.Background as Background @@ -154,6 +155,7 @@ viewNew device model = , device = device , heading = "Create a new pattern" , backToLabel = Just "Back to patterns" + , userPressedLogout = Just UserPressedLogout } , viewContent model ] @@ -306,6 +308,8 @@ type Msg | ChangedPattern Address (Pattern ()) | ChangedMeta Address Github.Meta | ChangedAddresses (List Address) + | UserPressedLogout + | ReceivedLogout (Result Http.Error ()) type StorageSolutionTag @@ -451,7 +455,7 @@ update msg model = Just address -> ( model , Cmd.batch - [ Route.pushUrl (Session.navKey model.session) (Route.Pattern address) + [ Route.pushUrl (Session.key model.session) (Route.Pattern address) , LocalStorage.updateAddresses (address :: addresses) ] ) @@ -459,6 +463,21 @@ update msg model = ChangedWhatever -> ( model, Cmd.none ) + UserPressedLogout -> + ( model + , Auth.logout model.session ReceivedLogout + ) + + ReceivedLogout result -> + case result of + Err _ -> + ( model, Cmd.none ) + + Ok _ -> + ( model + , Browser.Navigation.load "/" + ) + {-| -} subscriptions : Model -> Sub Msg diff --git a/assets/src/Page/Patterns.elm b/assets/src/Page/Patterns.elm index 5109405..bec673b 100644 --- a/assets/src/Page/Patterns.elm +++ b/assets/src/Page/Patterns.elm @@ -12,13 +12,13 @@ module Page.Patterns exposing -} +import Auth import Browser.Navigation import Element exposing (Element) import Github import Http import List.Extra as List import LocalStorage -import Route import Session exposing (Session) import Storage.Address as Address exposing (Address) import Time exposing (Posix) @@ -120,6 +120,7 @@ viewPatterns device model = , device = device , heading = "Patterns" , backToLabel = Nothing + , userPressedLogout = Just UserPressedLogout } , viewContent model ] @@ -141,7 +142,7 @@ viewContent model = , Element.centerX , Element.width (Element.fill - |> Element.maximum 780 + |> Element.maximum 860 ) ] [ Ui.Molecule.PatternList.view @@ -169,6 +170,8 @@ type Msg | ReceivedMeta Address (Result Http.Error Github.Meta) | ChangedWhatever | UserPressedClone Address + | UserPressedLogout + | ReceivedLogout (Result Http.Error ()) {-| -} @@ -229,12 +232,27 @@ updateLoaded msg model = UserPressedCreate -> ( model - , Browser.Navigation.pushUrl (Session.navKey model.session) "/new" + , Browser.Navigation.pushUrl (Session.key model.session) "/new" ) UserPressedClone _ -> ( model, Cmd.none ) + UserPressedLogout -> + ( model + , Auth.logout model.session ReceivedLogout + ) + + ReceivedLogout result -> + case result of + Err _ -> + ( model, Cmd.none ) + + Ok _ -> + ( model + , Browser.Navigation.load "/" + ) + _ -> ( model, Cmd.none ) diff --git a/assets/src/Page/Root.elm b/assets/src/Page/Root.elm new file mode 100644 index 0000000..b131c49 --- /dev/null +++ b/assets/src/Page/Root.elm @@ -0,0 +1,187 @@ +module Page.Root exposing + ( Model, init, toSession + , view + , Msg, update, subscriptions + ) + +{-| + +@docs Model, init, toSession +@docs view +@docs Msg, update, subscriptions + +-} + +import Browser.Navigation +import Element exposing (Element) +import Element.Background as Background +import Element.Border as Border +import Element.Font as Font +import Http +import Json.Encode as Encode +import Session exposing (Session) +import Ui.Atom.Input +import Ui.Theme.Color +import Ui.Theme.Spacing +import Ui.Theme.Typography + + + +---- MODEL + + +{-| -} +type Model + = Loaded LoadedData + + +type alias LoadedData = + { session : Session + } + + +{-| -} +init : Session -> ( Model, Cmd Msg ) +init session = + ( Loaded { session = session } + , Cmd.none + ) + + +{-| -} +toSession : Model -> Session +toSession model = + case model of + Loaded { session } -> + session + + + +---- VIEW + + +{-| -} +view : Model -> { title : String, body : Element Msg, dialog : Maybe (Element Msg) } +view (Loaded data) = + { title = "SewingLab" + , body = viewBody data + , dialog = Nothing + } + + +viewBody : LoadedData -> Element Msg +viewBody data = + Element.column + [ Element.width Element.fill + , Element.height Element.fill + , Element.spacing Ui.Theme.Spacing.level6 + , Background.color Ui.Theme.Color.secondary + ] + [ Element.el + [ Element.width Element.fill + , Background.color Ui.Theme.Color.complementaryLight + ] + (Element.el + [ Element.centerX + , Element.width (Element.fill |> Element.maximum 860) + , Element.paddingXY 0 Ui.Theme.Spacing.level7 + ] + (Ui.Theme.Typography.headingOne "SewingLab") + ) + , Element.column + [ Element.width (Element.fill |> Element.maximum 860) + , Element.height Element.fill + , Element.centerX + , Element.spacing Ui.Theme.Spacing.level8 + ] + [ Element.row + [ Element.spacing Ui.Theme.Spacing.level3 ] + [ infoBlock + , loginBlock + ] + ] + ] + + +infoBlock : Element msg +infoBlock = + Element.column + [ Element.width (Element.fillPortion 1) + , Element.spacing Ui.Theme.Spacing.level3 + ] + [ Ui.Theme.Typography.headingTwo "A place for sewing patterns" + , Ui.Theme.Typography.paragraphBody + [ Element.text "SewingLab is a platform for creating customizable sewing patterns and sharing them with other people. Create bespoke clothing with patterns which are dynamically generated from body measurements." + ] + ] + + +loginBlock : Element Msg +loginBlock = + Element.column + [ Element.width (Element.fillPortion 1) + , Background.color Ui.Theme.Color.white + , Element.padding Ui.Theme.Spacing.level3 + , Element.spacing Ui.Theme.Spacing.level2 + , Border.rounded 6 + , Border.width 1 + , Border.color Ui.Theme.Color.black + ] + [ Element.el [ Element.centerX ] (Ui.Theme.Typography.bodyBold "Join SewingLab") + , Element.column + [ Element.width Element.fill ] + [ Ui.Atom.Input.btnProviderFill + { id = "login-with-github" + , onPress = Just UserPressedLoginWithGithub + , icon = "github" + , label = "Log in with GitHub" + } + , Ui.Atom.Input.btnProviderFill + { id = "login-with-twitter" + , onPress = Just UserPressedLoginWithTwitter + , icon = "twitter" + , label = "Log in with Twitter" + } + ] + , Element.el [ Element.centerX ] <| + Ui.Theme.Typography.paragraphBody + [ Element.text "We require social login to prevent abuse." ] + ] + + + +---- UPDATE + + +{-| -} +type Msg + = UserPressedLoginWithGithub + | UserPressedLoginWithTwitter + + +{-| -} +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case model of + Loaded data -> + Tuple.mapFirst Loaded (updateLoaded msg data) + + +updateLoaded : Msg -> LoadedData -> ( LoadedData, Cmd Msg ) +updateLoaded msg data = + case msg of + UserPressedLoginWithGithub -> + ( data + , Browser.Navigation.load "/auth/github" + ) + + UserPressedLoginWithTwitter -> + ( data + , Browser.Navigation.load "/auth/twitter" + ) + + +{-| -} +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none diff --git a/assets/src/Route.elm b/assets/src/Route.elm index f451383..19a67dd 100644 --- a/assets/src/Route.elm +++ b/assets/src/Route.elm @@ -1,13 +1,13 @@ module Route exposing ( Route(..), NewParameters, fromUrl - , absolute, relative, crossOrigin + , absolute, relative , replaceUrl, pushUrl ) {-| @docs Route, NewParameters, fromUrl -@docs absolute, relative, crossOrigin +@docs absolute, relative @docs replaceUrl, pushUrl -} @@ -40,7 +40,7 @@ import Url.Parser.Query as Query {-| -} type Route - = Patterns + = Root | Pattern Address | PatternNew NewParameters | Details Address @@ -89,18 +89,10 @@ relative route otherParameters = (parameters route ++ otherParameters) -{-| -} -crossOrigin : String -> Route -> List QueryParameter -> String -crossOrigin domain route otherParameters = - Url.Builder.crossOrigin domain - (pathSegments route) - (parameters route ++ otherParameters) - - pathSegments : Route -> List String pathSegments route = case route of - Patterns -> + Root -> [] Pattern address -> @@ -116,7 +108,7 @@ pathSegments route = parameters : Route -> List QueryParameter parameters route = case route of - Patterns -> + Root -> [] Pattern _ -> @@ -143,8 +135,8 @@ parameters route = routeParser : Parser (Route -> a) a routeParser = oneOf - [ map Patterns top - , map Patterns (top s "app.html") + [ map Root top + , map Root (top s "app.html") , map Pattern (top Address.parser) , map PatternNew (top s "new" newParameters) , map Details (top Address.parser s "details") diff --git a/assets/src/Session.elm b/assets/src/Session.elm index cfbdc6a..e0faa23 100644 --- a/assets/src/Session.elm +++ b/assets/src/Session.elm @@ -1,53 +1,77 @@ module Session exposing ( Session - , navKey, domain - , anonymous + , key, csrfToken, viewer + , fromViewer ) {-| @docs Session -@docs navKey, domain -@docs anonymous +@docs key, csrfToken, viewer +@docs fromViewer -} import Browser.Navigation -import Route exposing (Route) -import Url.Builder exposing (QueryParameter) +import Viewer exposing (Viewer) {-| -} type Session - = Anonymous SessionData + = Guest SessionData + | LoggedIn Viewer SessionData type alias SessionData = { key : Browser.Navigation.Key - , domain : String + , csrfToken : String } {-| -} -navKey : Session -> Browser.Navigation.Key -navKey session = +key : Session -> Browser.Navigation.Key +key session = case session of - Anonymous { key } -> - key + Guest stuff -> + stuff.key + + LoggedIn _ stuff -> + stuff.key + + +{-| -} +csrfToken : Session -> String +csrfToken session = + case session of + Guest stuff -> + stuff.csrfToken + + LoggedIn _ stuff -> + stuff.csrfToken {-| -} -domain : Session -> String -domain session = +viewer : Session -> Maybe Viewer +viewer session = case session of - Anonymous stuff -> - stuff.domain + Guest _ -> + Nothing + + LoggedIn val _ -> + Just val {-| -} -anonymous : Browser.Navigation.Key -> String -> Session -anonymous key domain_ = - Anonymous - { key = key - , domain = domain_ - } +fromViewer : + { key : Browser.Navigation.Key + , csrfToken : String + } + -> Maybe Viewer + -> Session +fromViewer data maybeViewer = + case maybeViewer of + Nothing -> + Guest data + + Just val -> + LoggedIn val data diff --git a/assets/src/Stories.elm b/assets/src/Stories.elm index 68f107a..e054e5b 100644 --- a/assets/src/Stories.elm +++ b/assets/src/Stories.elm @@ -2,7 +2,9 @@ port module Stories exposing (main) import Bulletproof import Bulletproof.Knob -import Element +import Element exposing (Element) +import Element.Background as Background +import Element.Border as Border import Element.Font as Font import Html.Attributes import Length exposing (millimeters) @@ -18,6 +20,8 @@ import Ui.Atom.Marker import Ui.Molecule.Modal import Ui.Molecule.PatternList import Ui.Organism.Dialog +import Ui.Theme.Color +import Ui.Theme.Spacing import Ui.Theme.Typography @@ -27,7 +31,10 @@ port saveSettings : String -> Cmd msg main : Bulletproof.Program main = Bulletproof.program saveSettings - [ Bulletproof.folder "Atom" + [ Bulletproof.folder "Theme" + [ color + ] + , Bulletproof.folder "Atom" [ Bulletproof.folder "Input" [ buttons , iconButtons @@ -42,10 +49,48 @@ main = , Bulletproof.folder "Organism" [ dialogs ] + , Bulletproof.folder "Page" + [ landingpage + , login + , signup + ] , marker ] +color : Bulletproof.Story +color = + let + block color_ = + Element.el + [ Element.width Element.fill + , Element.height (Element.px 128) + , Background.color color_ + ] + Element.none + in + Bulletproof.story "Color" + (Element.column + [ Element.spacing 16 + , Element.width Element.fill + ] + [ block Ui.Theme.Color.primaryBright + , block Ui.Theme.Color.primaryLight + , block Ui.Theme.Color.primary + , block Ui.Theme.Color.primaryDark + , block Ui.Theme.Color.secondary + , block Ui.Theme.Color.secondaryDark + , block Ui.Theme.Color.danger + , block Ui.Theme.Color.dangerDark + , block Ui.Theme.Color.success + , block Ui.Theme.Color.white + , block Ui.Theme.Color.black + , block Ui.Theme.Color.grayDark + ] + |> fromElmUI + ) + + buttons : Bulletproof.Story buttons = Bulletproof.folder "Buttons" @@ -330,6 +375,228 @@ dialogs = ] +landingpage : Bulletproof.Story +landingpage = + Bulletproof.story "landingpage" + (Element.column + [ Element.width Element.fill + , Element.height Element.fill + , Background.color Ui.Theme.Color.secondary + ] + [ Element.row + [ Element.width Element.fill + , Element.paddingXY Ui.Theme.Spacing.level8 Ui.Theme.Spacing.level3 + ] + [ Element.el + [ Element.alignLeft ] + (Ui.Theme.Typography.headingOne "SewingLab") + , Element.row + [ Element.alignRight ] + [ Ui.Atom.Input.btnSecondary + { id = "login" + , onPress = Nothing + , label = "Log in" + } + , Ui.Atom.Input.btnPrimary + { id = "signup" + , onPress = Nothing + , label = "Sign up" + } + ] + ] + , Element.row + [ Element.width (Element.fill |> Element.maximum 1024) + , Element.centerX + , Element.padding Ui.Theme.Spacing.level8 + , Element.spacing Ui.Theme.Spacing.level3 + ] + [ Element.column + [ Element.width (Element.fillPortion 1) + , Element.spacing Ui.Theme.Spacing.level3 + ] + [ Ui.Theme.Typography.headingTwo "A place for sewing patterns" + , Ui.Theme.Typography.paragraphBody + [ Element.text "SewingLab is a platform for creating customizable sewing patterns and sharing them with other people. Create bespoke clothing with patterns which are dynamically generated from body measurements." + ] + ] + , Element.column + [ Element.width (Element.fillPortion 1) + , Background.color Ui.Theme.Color.white + , Element.padding Ui.Theme.Spacing.level3 + , Element.spacing Ui.Theme.Spacing.level2 + , Border.rounded 6 + ] + [ Ui.Atom.Input.text + { id = "email" + , onChange = \_ -> () + , text = "" + , label = "Email" + , help = Nothing + } + , Ui.Atom.Input.text + { id = "username" + , onChange = \_ -> () + , text = "" + , label = "Username" + , help = Nothing + } + , Ui.Atom.Input.text + { id = "password" + , onChange = \_ -> () + , text = "" + , label = "Password" + , help = Nothing + } + , Ui.Atom.Input.btnPrimaryFill + { id = "signup" + , onPress = Nothing + , label = "Sign up to SewingLab" + } + ] + ] + ] + |> fromElmUI + ) + + +login : Bulletproof.Story +login = + Bulletproof.story "login" + (Element.el + [ Element.width Element.fill + , Element.height Element.fill + , Background.color Ui.Theme.Color.secondary + , Element.padding Ui.Theme.Spacing.level8 + ] + (Element.column + [ Element.centerX + , Element.width (Element.px 640) + , Element.padding Ui.Theme.Spacing.level5 + , Element.spacing Ui.Theme.Spacing.level4 + , Background.color Ui.Theme.Color.white + , Border.rounded 6 + ] + [ Element.el + [ Element.centerX ] + (Ui.Theme.Typography.headingTwo "Log in to SewingLab") + , Element.el + [ Element.padding 7 ] + (Ui.Theme.Typography.paragraphBody + [ Element.text "Need a SewingLab account? " + , Element.link + [ Font.color Ui.Theme.Color.primary + , Font.underline + , Element.focused + [ Border.color Ui.Theme.Color.primaryDark ] + ] + { url = "#" + , label = Element.text "Create an account" + } + ] + ) + , Element.column + [ Element.width Element.fill + , Element.spacing Ui.Theme.Spacing.level2 + ] + [ Ui.Atom.Input.text + { id = "usernameOrEmail" + , onChange = \_ -> () + , text = "" + , label = "Username or email address" + , help = Nothing + } + , Ui.Atom.Input.text + { id = "password" + , onChange = \_ -> () + , text = "" + , label = "Password" + , help = Nothing + } + , Ui.Atom.Input.btnPrimary + { id = "login" + , onPress = Nothing + , label = "Log in" + } + ] + ] + ) + |> fromElmUI + ) + + +signup : Bulletproof.Story +signup = + Bulletproof.story "signup" + (Element.el + [ Element.width Element.fill + , Element.height Element.fill + , Background.color Ui.Theme.Color.secondary + , Element.padding Ui.Theme.Spacing.level8 + ] + (Element.column + [ Element.centerX + , Element.width (Element.px 640) + , Element.padding Ui.Theme.Spacing.level5 + , Element.spacing Ui.Theme.Spacing.level4 + , Background.color Ui.Theme.Color.white + , Border.rounded 6 + ] + [ Element.el + [ Element.centerX ] + (Ui.Theme.Typography.headingTwo "Get started with your account") + , Element.el + [ Element.padding 7 ] + (Ui.Theme.Typography.paragraphBody + [ Element.text "Find sewing patterns. Adjust them to your needs or create your own. Already have an account? " + , Element.link + [ Font.color Ui.Theme.Color.primary + , Font.underline + , Element.focused + [ Border.color Ui.Theme.Color.primaryDark ] + ] + { url = "#" + , label = Element.text "Log in" + } + ] + ) + , Element.column + [ Element.width Element.fill + , Element.spacing Ui.Theme.Spacing.level2 + ] + [ Ui.Atom.Input.text + { id = "email" + , onChange = \_ -> () + , text = "" + , label = "Email" + , help = Nothing + } + , Ui.Atom.Input.text + { id = "username" + , onChange = \_ -> () + , text = "" + , label = "Username" + , help = Nothing + } + , Ui.Atom.Input.text + { id = "password" + , onChange = \_ -> () + , text = "" + , label = "Password" + , help = Nothing + } + , Ui.Atom.Input.btnPrimary + { id = "signup" + , onPress = Nothing + , label = "Sign up" + } + ] + ] + ) + |> fromElmUI + ) + + +marker : Bulletproof.Story marker = let resolution = @@ -407,6 +674,7 @@ marker = ---- FROM ELM UI +fromElmUI : Element msg -> Bulletproof.Renderer fromElmUI = Bulletproof.fromElmUI [ Element.focusStyle diff --git a/assets/src/Ui/Atom/Input.elm b/assets/src/Ui/Atom/Input.elm index da36351..fa5361f 100644 --- a/assets/src/Ui/Atom/Input.elm +++ b/assets/src/Ui/Atom/Input.elm @@ -5,8 +5,10 @@ module Ui.Atom.Input exposing , IconBtnConfig, btnIcon, btnIconDanger, btnIconLarge , CheckboxConfig, checkbox , TextConfig, text, textAppended, formula, formulaAppended + , email, username, newPassword, currentPassword , RadioConfig, radioRow, radioColumn, OptionConfig, option , SegmentControlConfig, segmentControl, Child(..), nested, nestedHideable + , btnPrimaryFill, btnProviderFill ) {-| @@ -28,6 +30,7 @@ module Ui.Atom.Input exposing # Text @docs TextConfig, text, textAppended, formula, formulaAppended +@docs email, username, newPassword, currentPassword # Radio Selection @@ -84,6 +87,24 @@ btnPrimary { id, onPress, label } = } +{-| -} +btnPrimaryFill : BtnConfig msg -> Element msg +btnPrimaryFill { id, onPress, label } = + Ui.Theme.Focus.outlineFill <| + Input.button + [ attributeId id + , Element.width Element.fill + , Element.paddingXY Ui.Theme.Spacing.level3 Ui.Theme.Spacing.level2 + , Font.color Ui.Theme.Color.white + , Background.color Ui.Theme.Color.primaryLight + , Element.mouseOver [ Background.color Ui.Theme.Color.primary ] + , backgroundColorEaseInOut + ] + { onPress = onPress + , label = Element.el [ Element.centerX ] (Ui.Theme.Typography.button label) + } + + {-| -} btnSecondary : BtnConfig msg -> Element msg btnSecondary { id, onPress, label } = @@ -100,6 +121,40 @@ btnSecondary { id, onPress, label } = } +{-| -} +type alias BtnProviderConfig msg = + { id : String + , onPress : Maybe msg + , icon : String + , label : String + } + + +{-| -} +btnProviderFill : BtnProviderConfig msg -> Element msg +btnProviderFill { id, onPress, icon, label } = + Ui.Theme.Focus.outlineFill <| + Input.button + [ attributeId id + , Element.width Element.fill + , Element.paddingXY Ui.Theme.Spacing.level3 Ui.Theme.Spacing.level2 + , Background.color Ui.Theme.Color.secondary + , Element.mouseOver [ Background.color Ui.Theme.Color.secondaryDark ] + , backgroundColorEaseInOut + , Border.rounded 24 + ] + { onPress = onPress + , label = + Element.row + [ Element.spacing Ui.Theme.Spacing.level2 + , Element.centerX + ] + [ Ui.Atom.Icon.faBrandLarge icon + , Ui.Theme.Typography.button label + ] + } + + {-| -} btnSecondaryBorderedLeft : BtnConfig msg -> Element msg btnSecondaryBorderedLeft { id, onPress, label } = @@ -365,35 +420,9 @@ type alias TextConfig msg = {-| -} text : TextConfig msg -> Element msg text data = - let - withShadow attrs = - if data.help == Nothing then - [ Element.focused - [ Border.color Ui.Theme.Color.primary - , focusShadow - ] - , Border.color Ui.Theme.Color.black - ] - ++ attrs - - else - [ dangerShadow - , Border.color Ui.Theme.Color.danger - ] - ++ attrs - in Ui.Theme.Focus.outlineFill <| Input.text - (withShadow - [ attributeId data.id - , Element.width Element.fill - , Element.padding 10 - , Font.size 16 - , Background.color Ui.Theme.Color.white - , Border.rounded 3 - , Border.width 1 - ] - ) + (textAttributes data.id data.help) { onChange = data.onChange , text = data.text , placeholder = Nothing @@ -453,26 +482,10 @@ formula data = , top = 10 , bottom = 10 } - - withShadow attrs = - if data.help == Nothing then - [ Element.focused - [ Border.color Ui.Theme.Color.primary - , focusShadow - ] - , Border.color Ui.Theme.Color.black - ] - ++ attrs - - else - [ dangerShadow - , Border.color Ui.Theme.Color.danger - ] - ++ attrs in Ui.Theme.Focus.outlineFill <| Input.multiline - (withShadow + (withShadow data.help [ attributeId data.id , Element.width Element.fill , Element.inFront (lineNumbers lineCount) @@ -592,6 +605,107 @@ lineNumbers lineCount = ] +{-| -} +email : TextConfig msg -> Element msg +email data = + Ui.Theme.Focus.outlineFill <| + Input.email + (textAttributes data.id data.help) + { onChange = data.onChange + , text = data.text + , placeholder = Nothing + , label = + labelAbove + { label = data.label + , help = data.help + } + } + + +{-| -} +username : TextConfig msg -> Element msg +username data = + Ui.Theme.Focus.outlineFill <| + Input.username + (textAttributes data.id data.help) + { onChange = data.onChange + , text = data.text + , placeholder = Nothing + , label = + labelAbove + { label = data.label + , help = data.help + } + } + + +{-| -} +newPassword : TextConfig msg -> Element msg +newPassword data = + Ui.Theme.Focus.outlineFill <| + Input.newPassword + (textAttributes data.id data.help) + { onChange = data.onChange + , text = data.text + , placeholder = Nothing + , label = + labelAbove + { label = data.label + , help = data.help + } + , show = False + } + + +{-| -} +currentPassword : TextConfig msg -> Element msg +currentPassword data = + Ui.Theme.Focus.outlineFill <| + Input.currentPassword + (textAttributes data.id data.help) + { onChange = data.onChange + , text = data.text + , placeholder = Nothing + , label = + labelAbove + { label = data.label + , help = data.help + } + , show = False + } + + +textAttributes : String -> Maybe help -> List (Element.Attribute msg) +textAttributes id help = + withShadow help + [ attributeId id + , Element.width Element.fill + , Element.padding 10 + , Font.size 16 + , Background.color Ui.Theme.Color.white + , Border.rounded 3 + , Border.width 1 + ] + + +withShadow : Maybe help -> List (Element.Attribute msg) -> List (Element.Attribute msg) +withShadow help attrs = + if help == Nothing then + [ Element.focused + [ Border.color Ui.Theme.Color.primary + , focusShadow + ] + , Border.color Ui.Theme.Color.black + ] + ++ attrs + + else + [ dangerShadow + , Border.color Ui.Theme.Color.danger + ] + ++ attrs + + ---- RADIO SELECTION diff --git a/assets/src/Ui/Molecule/TopBar.elm b/assets/src/Ui/Molecule/TopBar.elm index 135daae..9705558 100644 --- a/assets/src/Ui/Molecule/TopBar.elm +++ b/assets/src/Ui/Molecule/TopBar.elm @@ -12,15 +12,16 @@ import Ui.Theme.Spacing import Ui.Theme.Typography -type alias Config = +type alias Config msg = { cred : Github.Cred , device : Element.Device , backToLabel : Maybe String , heading : String + , userPressedLogout : Maybe msg } -view : Config -> Element msg +view : Config msg -> Element msg view cfg = let backToPatternsLink = @@ -46,9 +47,20 @@ view cfg = } heading = - Element.el - [ Element.centerY ] - (Ui.Theme.Typography.headingOne cfg.heading) + Ui.Theme.Typography.headingOne cfg.heading + + logout = + case cfg.userPressedLogout of + Nothing -> + Element.none + + Just userPressedLogout -> + Element.el [ Element.alignRight ] <| + Ui.Atom.Input.btnSecondary + { id = "logout" + , onPress = Just userPressedLogout + , label = "Log out" + } -- COMPACT compact = @@ -86,16 +98,14 @@ view cfg = backToPatternsLink , Element.el [ Element.centerX + , Element.width + (Element.fill + |> Element.maximum 860 + ) ] - (Element.el - [ Element.width - (Element.fill - |> Element.maximum 780 - ) - ] - heading - ) - , Element.el [ Element.width Element.fill ] Element.none + heading + , Element.el [ Element.width Element.fill ] + logout ] in case ( cfg.device.class, cfg.device.orientation ) of diff --git a/assets/src/Ui/Organism/Dialog/Detail.elm b/assets/src/Ui/Organism/Dialog/Detail.elm index b49c0c3..5a2a490 100644 --- a/assets/src/Ui/Organism/Dialog/Detail.elm +++ b/assets/src/Ui/Organism/Dialog/Detail.elm @@ -363,15 +363,14 @@ initWith initPointWith pattern aDetail = close nextCurve = ( nextCurve, Closed ) in - case Pattern.detailInfo aDetail pattern of - Nothing -> - Nothing - - Just info -> - Maybe.map3 toForm - (initFCurveWith initPointWith pattern info.firstCurve) - (initNextCurvesFormWith initPointWith pattern info.nextCurves) - (initLCurveWith initPointWith pattern info.lastCurve) + Pattern.detailInfo aDetail pattern + |> Maybe.andThen + (\info -> + Maybe.map3 toForm + (initFCurveWith initPointWith pattern info.firstCurve) + (initNextCurvesFormWith initPointWith pattern info.nextCurves) + (initLCurveWith initPointWith pattern info.lastCurve) + ) initFCurveWith : diff --git a/assets/src/Ui/Organism/Dialog/Intersectable.elm b/assets/src/Ui/Organism/Dialog/Intersectable.elm index b3a9c00..e73cc08 100644 --- a/assets/src/Ui/Organism/Dialog/Intersectable.elm +++ b/assets/src/Ui/Organism/Dialog/Intersectable.elm @@ -283,9 +283,30 @@ clear clearIntersectable form = type alias ViewConfig coordinates axisForm axisMsg circleForm circleMsg curveForm curveMsg = - { axis : Pattern coordinates -> Pattern.Objects -> { axis : axisForm, id : String } -> Element axisMsg - , circle : Pattern coordinates -> Pattern.Objects -> { circle : circleForm, id : String } -> Element circleMsg - , curve : Pattern coordinates -> Pattern.Objects -> { curve : curveForm, id : String } -> Element curveMsg + { axis : + Pattern coordinates + -> Pattern.Objects + -> + { axis : axisForm + , id : String + } + -> Element axisMsg + , circle : + Pattern coordinates + -> Pattern.Objects + -> + { circle : circleForm + , id : String + } + -> Element circleMsg + , curve : + Pattern coordinates + -> Pattern.Objects + -> + { curve : curveForm + , id : String + } + -> Element curveMsg } diff --git a/assets/src/Ui/Theme/Color.elm b/assets/src/Ui/Theme/Color.elm index 3873b0b..c2807dc 100644 --- a/assets/src/Ui/Theme/Color.elm +++ b/assets/src/Ui/Theme/Color.elm @@ -1,7 +1,7 @@ module Ui.Theme.Color exposing ( primaryBright, primaryLight, primary, primaryDark , secondary, secondaryDark - , complementary, complementaryDark + , complementaryLight, complementary, complementaryDark , neutral, neutralDark , danger, dangerDark , success @@ -22,7 +22,7 @@ module Ui.Theme.Color exposing @docs primaryBright, primaryLight, primary, primaryDark @docs secondary, secondaryDark -@docs complementary, complementaryDark +@docs complementaryLight, complementary, complementaryDark @docs neutral, neutralDark @docs danger, dangerDark @docs success @@ -80,6 +80,12 @@ secondaryDark = Element.rgb255 217 215 205 +{-| -} +complementaryLight : Color +complementaryLight = + Element.rgb255 225 178 115 + + {-| -} complementary : Color complementary = diff --git a/assets/src/Ui/Theme/Typography.elm b/assets/src/Ui/Theme/Typography.elm index 48c53a1..add3ed3 100644 --- a/assets/src/Ui/Theme/Typography.elm +++ b/assets/src/Ui/Theme/Typography.elm @@ -52,6 +52,7 @@ heading : { level : Int, fontSize : Int } -> String -> Element msg heading { level, fontSize } text = Element.paragraph [ Font.size fontSize + , Font.bold , Region.heading level ] [ Element.text text ] diff --git a/assets/src/Viewer.elm b/assets/src/Viewer.elm new file mode 100644 index 0000000..7142b76 --- /dev/null +++ b/assets/src/Viewer.elm @@ -0,0 +1,12 @@ +module Viewer exposing (Viewer, decoder) + +import Json.Decode as Decode exposing (Decoder) + + +type Viewer + = Viewer + + +decoder : Decoder Viewer +decoder = + Decode.succeed Viewer diff --git a/assets/unbuffered-elm b/assets/unbuffered-elm new file mode 100755 index 0000000..51d3c6e --- /dev/null +++ b/assets/unbuffered-elm @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +unbuffer elm $@ diff --git a/assets/webpack.config.js b/assets/webpack.config.js index 5691c15..c854dcf 100644 --- a/assets/webpack.config.js +++ b/assets/webpack.config.js @@ -39,7 +39,8 @@ module.exports = (env, options) => ({ use: { loader: 'elm-webpack-loader', options: { - debug: options.mode === "development" + debug: options.mode === "development", + pathToElm: `${path.resolve(process.cwd())}/unbuffered-elm` } } } diff --git a/config/config.exs b/config/config.exs index e82f329..c603cff 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,6 +26,26 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :ueberauth, Ueberauth, + providers: [ + identity: { + Ueberauth.Strategy.Identity, [ + callback_methods: ["POST"], + uid_field: :email, + ], + }, + github: { Ueberauth.Strategy.Github, [default_scope: ""] }, + twitter: { Ueberauth.Strategy.Twitter, [] }, + ] + +config :ueberauth, Ueberauth.Strategy.Github.OAuth, + client_id: System.get_env("GITHUB_CLIENT_ID"), + client_secret: System.get_env("GITHUB_CLIENT_SECRET") + +config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET") + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/test.exs b/config/test.exs index 25a8dfc..a07ef8f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -5,7 +5,7 @@ config :hub, Hub.Repo, username: "postgres", password: "postgres", database: "hub_test", - hostname: "localhost", + hostname: "10.233.1.2", pool: Ecto.Adapters.SQL.Sandbox # We don't run a server during test. If one is required, diff --git a/lib/hub/accounts.ex b/lib/hub/accounts.ex index 82b56f5..700ef59 100644 --- a/lib/hub/accounts.ex +++ b/lib/hub/accounts.ex @@ -6,7 +6,7 @@ defmodule Hub.Accounts do import Ecto.Query, warn: false alias Hub.Repo - alias Hub.Accounts.{User, Credential} + alias Hub.Accounts.User @doc """ Returns the list of users. @@ -20,7 +20,6 @@ defmodule Hub.Accounts do def list_users do User |> Repo.all() - |> Repo.preload(:credential) end @doc """ @@ -40,7 +39,6 @@ defmodule Hub.Accounts do def get_user!(id) do User |> Repo.get!(id) - |> Repo.preload(:credential) end @doc """ @@ -58,10 +56,15 @@ defmodule Hub.Accounts do def create_user(attrs \\ %{}) do %User{} |> User.changeset(attrs) - |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2) |> Repo.insert() end + def get_user_from_id(id) when is_nil(id), do: nil + + def get_user_from_id(id) do + Repo.get(User, id) + end + @doc """ Updates a user. @@ -77,7 +80,6 @@ defmodule Hub.Accounts do def update_user(%User{} = user, attrs) do user |> User.changeset(attrs) - |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2) |> Repo.update() end @@ -109,112 +111,4 @@ defmodule Hub.Accounts do def change_user(%User{} = user) do User.changeset(user, %{}) end - - alias Hub.Accounts.Credential - - @doc """ - Returns the list of credentials. - - ## Examples - - iex> list_credentials() - [%Credential{}, ...] - - """ - def list_credentials do - Repo.all(Credential) - end - - @doc """ - Gets a single credential. - - Raises `Ecto.NoResultsError` if the Credential does not exist. - - ## Examples - - iex> get_credential!(123) - %Credential{} - - iex> get_credential!(456) - ** (Ecto.NoResultsError) - - """ - def get_credential!(id), do: Repo.get!(Credential, id) - - @doc """ - Creates a credential. - - ## Examples - - iex> create_credential(%{field: value}) - {:ok, %Credential{}} - - iex> create_credential(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_credential(attrs \\ %{}) do - %Credential{} - |> Credential.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a credential. - - ## Examples - - iex> update_credential(credential, %{field: new_value}) - {:ok, %Credential{}} - - iex> update_credential(credential, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_credential(%Credential{} = credential, attrs) do - credential - |> Credential.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a credential. - - ## Examples - - iex> delete_credential(credential) - {:ok, %Credential{}} - - iex> delete_credential(credential) - {:error, %Ecto.Changeset{}} - - """ - def delete_credential(%Credential{} = credential) do - Repo.delete(credential) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking credential changes. - - ## Examples - - iex> change_credential(credential) - %Ecto.Changeset{source: %Credential{}} - - """ - def change_credential(%Credential{} = credential) do - Credential.changeset(credential, %{}) - end - - def authenticate_by_email_password(email, _password) do - query = - from user in User, - inner_join: credential in assoc(user, :credential), - where: credential.email == ^email - - case Repo.one(query) do - %User{} = user -> {:ok, user} - nil -> {:error, :unauthorized} - end - end end diff --git a/lib/hub/accounts/authorization.ex b/lib/hub/accounts/authorization.ex new file mode 100644 index 0000000..583a4b0 --- /dev/null +++ b/lib/hub/accounts/authorization.ex @@ -0,0 +1,30 @@ +defmodule Hub.Accounts.Authorization do + use Ecto.Schema + import Ecto.Changeset + + schema "authorizations" do + field :provider, :string + field :uid, :string + field :token, :string + field :refresh_token, :string + field :expires_at, :integer + field :password, :string, virtual: true + field :password_confirmation, :string, virtual: true + + belongs_to :user, Hub.Accounts.User + + timestamps() + end + + @required_fields ~w(provider uid user_id token)a + @optional_fields ~w(refresh_token expires_at)a + + @doc false + def changeset(authorization, attrs) do + authorization + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:provider_uid) + end +end diff --git a/lib/hub/accounts/credential.ex b/lib/hub/accounts/credential.ex deleted file mode 100644 index 1f47f23..0000000 --- a/lib/hub/accounts/credential.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Hub.Accounts.Credential do - use Ecto.Schema - import Ecto.Changeset - - alias Hub.Accounts.User - - schema "credentials" do - field :email, :string - belongs_to :user, User - - timestamps() - end - - @doc false - def changeset(credential, attrs) do - credential - |> cast(attrs, [:email]) - |> validate_required([:email]) - |> unique_constraint(:email) - end -end diff --git a/lib/hub/accounts/user.ex b/lib/hub/accounts/user.ex index 2bbca23..a59def0 100644 --- a/lib/hub/accounts/user.ex +++ b/lib/hub/accounts/user.ex @@ -2,21 +2,16 @@ defmodule Hub.Accounts.User do use Ecto.Schema import Ecto.Changeset - alias Hub.Accounts.Credential - schema "users" do - field :name, :string - field :username, :string - has_one :credential, Credential + has_many :authorizations, Hub.Accounts.Authorization timestamps() end @doc false - def changeset(user, attrs) do + def changeset(user, attrs \\ %{}) do user - |> cast(attrs, [:name, :username]) - |> validate_required([:name, :username]) - |> unique_constraint(:username) + |> cast(attrs, []) + |> validate_required([]) end end diff --git a/lib/hub_web/controllers/auth_controller.ex b/lib/hub_web/controllers/auth_controller.ex new file mode 100644 index 0000000..1b808d7 --- /dev/null +++ b/lib/hub_web/controllers/auth_controller.ex @@ -0,0 +1,47 @@ +defmodule HubWeb.AuthController do + use HubWeb, :controller + + plug Ueberauth + + alias Ueberauth.Strategy.Helpers + alias HubWeb.Auth + + + def request(conn, _params) do + render(conn, "request.html", %{ + callback_url: Helpers.callback_url(conn), + layout: {HubWeb.LayoutView, "app.html"}}) + end + + def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do + conn + |> redirect(to: "/") + end + + def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do + case UserFromAuth.find_or_create(auth) do + {:ok, user} -> + conn + |> Auth.put_current_user(user) + |> redirect(to: "/") + + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> halt() + |> json(%{error: "unauthorized"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> halt() + |> json(%{error: "not found"}) + end + end + + def delete(conn, _params) do + conn + |> Auth.drop_current_user() + |> json(%{}) + end +end diff --git a/lib/hub_web/controllers/user_controller.ex b/lib/hub_web/controllers/user_controller.ex index e20e5fc..72e6b25 100644 --- a/lib/hub_web/controllers/user_controller.ex +++ b/lib/hub_web/controllers/user_controller.ex @@ -1,43 +1,9 @@ defmodule HubWeb.UserController do use HubWeb, :controller - alias Hub.Accounts - alias Hub.Accounts.User - action_fallback HubWeb.FallbackController - def index(conn, _params) do - users = Accounts.list_users() - render(conn, "index.json", users: users) - end - - def create(conn, %{"user" => user_params}) do - with {:ok, %User{} = user} <- Accounts.create_user(user_params) do - conn - |> put_status(:created) - |> put_resp_header("location", Routes.user_path(conn, :show, user)) - |> render("show.json", user: user) - end - end - - def show(conn, %{"id" => id}) do - user = Accounts.get_user!(id) + def show(conn = %{assigns: %{current_user: user}}, _params) do render(conn, "show.json", user: user) end - - def update(conn, %{"id" => id, "user" => user_params}) do - user = Accounts.get_user!(id) - - with {:ok, %User{} = user} <- Accounts.update_user(user, user_params) do - render(conn, "show.json", user: user) - end - end - - def delete(conn, %{"id" => id}) do - user = Accounts.get_user!(id) - - with {:ok, %User{}} <- Accounts.delete_user(user) do - send_resp(conn, :no_content, "") - end - end end diff --git a/lib/hub_web/models/user_from_auth.ex b/lib/hub_web/models/user_from_auth.ex new file mode 100644 index 0000000..f05409b --- /dev/null +++ b/lib/hub_web/models/user_from_auth.ex @@ -0,0 +1,129 @@ +defmodule UserFromAuth do + @moduledoc """ + Retrieve the user information from the auth request + """ + require Logger + import Ecto.Query + + alias Hub.Repo + alias Ueberauth.Auth + alias Hub.Accounts + + def find_or_create(auth) do + case auth_and_validate(auth) do + {:error, :not_found} -> + register_user_from_auth(auth) + + {:error, reason} -> + {:error, reason} + + authorization -> + user_from_authorization(authorization) + end + end + + + # AUTH AND VALIDATE + + defp auth_and_validate(%Auth{provider: provider} = auth) do + query = from a in Accounts.Authorization, + where: a.uid == ^to_string(auth.uid) and a.provider == ^to_string(provider) + + case Repo.one(query) do + nil -> + {:error, :not_found} + + authorization -> + if authorization.uid == to_string(auth.uid) do + authorization + else + {:error, :uid_mismatch} + end + end + end + + + # REGISTER USER FROM AUTH + + defp register_user_from_auth(auth) do + case Repo.transaction(fn -> create_user_from_auth(auth) end) do + {:error, reason} -> + {:error, reason} + + {:ok, response} -> + response + end + end + + defp create_user_from_auth(auth) do + user = create_user() + create_authorization(user, auth) + {:ok, user} + end + + defp create_user do + result = + Accounts.User.changeset(%Accounts.User{}) + |> Repo.insert + + case result do + {:error, reason} -> + Repo.rollback(reason) + + {:ok, user} -> + user + end + end + + defp create_authorization(user, %Auth{provider: provider} = auth) do + authorization = Ecto.build_assoc(user, :authorizations) + result = Accounts.Authorization.changeset( + authorization, + scrub( + %{ + provider: to_string(provider), + uid: to_string(auth.uid), + token: auth.credentials.token, + refresh_token: auth.credentials.refresh_token, + expires_at: auth.credentials.expires_at, + } + ) + ) + |> Repo.insert + + case result do + {:error, reason} -> + Repo.rollback(reason) + + {:ok, _} -> + :ok + end + end + + + # USER FROM AUTHORIZATION + + defp user_from_authorization(auth) do + case Repo.one(Ecto.assoc(auth, :user)) do + nil -> + {:error, :user_not_found} + + user -> + {:ok, user} + end + end + + + # HELPER + + defp scrub(params) do + Enum.filter(params, fn + {_key, val} when is_binary(val) -> + String.trim(val) != "" + + {_key, val} when is_nil(val) -> + false + end) + |> Enum.into(%{}) + end +end diff --git a/lib/hub_web/plugs/auth.ex b/lib/hub_web/plugs/auth.ex new file mode 100644 index 0000000..d716bd5 --- /dev/null +++ b/lib/hub_web/plugs/auth.ex @@ -0,0 +1,42 @@ +defmodule HubWeb.Auth do + import Plug.Conn + import Phoenix.Controller + + alias Hub.Accounts + + def init(opts), do: opts + + def call(conn, _opts) do + user_id = get_session(conn, :user_id) + + user = + cond do + assigned = conn.assigns[:current_user] -> assigned + true -> Accounts.get_user_from_id(user_id) + end + + put_current_user(conn, user) + |> logged_in_user(%{}) + end + + def logged_in_user(conn = %{assigns: %{current_user: %{}}}, _), do: conn + + def logged_in_user(conn, _opts) do + conn + |> put_status(:unauthorized) + |> halt() + |> json(%{error: "unauthorized"}) + end + + def put_current_user(conn, user) do + conn + |> assign(:current_user, user) + |> put_session(:user_id, user && user.id) + |> configure_session(renew: true) + end + + def drop_current_user(conn) do + conn + |> configure_session(drop: true) + end +end diff --git a/lib/hub_web/router.ex b/lib/hub_web/router.ex index fb164b2..81d2461 100644 --- a/lib/hub_web/router.ex +++ b/lib/hub_web/router.ex @@ -6,7 +6,6 @@ defmodule HubWeb.Router do plug :fetch_session plug :protect_from_forgery plug :put_secure_browser_headers - plug :authenticate_user end pipeline :api do @@ -14,14 +13,30 @@ defmodule HubWeb.Router do plug :fetch_session plug :protect_from_forgery plug :put_secure_browser_headers - plug :authenticate_user + end + + pipeline :auth do + plug HubWeb.Auth + end + + + scope "/auth", HubWeb do + pipe_through :browser + + get "/:provider", AuthController, :request + get "/:provider/callback", AuthController, :callback + end + + scope "/auth", HubWeb do + pipe_through [:api, :auth] + + delete "/logout", AuthController, :delete end scope "/api", HubWeb do - pipe_through :api + pipe_through [:api, :auth] - resources "/sessions", SessionController, only: [:create, :show, :delete], singleton: true - resources "/users", UserController + get "/user", UserController, :show end scope "/", HubWeb do @@ -30,11 +45,4 @@ defmodule HubWeb.Router do get "/story*anything", PageController, :story get "/*anything", PageController, :index end - - defp authenticate_user(conn, _) do - case get_session(conn, :user_id) do - nil -> conn - user_id -> assign(conn, :current_user, Hub.Accounts.get_user!(user_id)) - end - end end diff --git a/lib/hub_web/views/user_view.ex b/lib/hub_web/views/user_view.ex index 8d58af0..6c93576 100644 --- a/lib/hub_web/views/user_view.ex +++ b/lib/hub_web/views/user_view.ex @@ -2,17 +2,11 @@ defmodule HubWeb.UserView do use HubWeb, :view alias HubWeb.UserView - def render("index.json", %{users: users}) do - %{data: render_many(users, UserView, "user.json")} - end - def render("show.json", %{user: user}) do %{data: render_one(user, UserView, "user.json")} end - def render("user.json", %{user: user}) do - %{id: user.id, - name: user.name, - username: user.username} + def render("user.json", %{user: _user}) do + %{} end end diff --git a/mix.exs b/mix.exs index 1596f0c..4f53abb 100644 --- a/mix.exs +++ b/mix.exs @@ -20,7 +20,13 @@ defmodule Hub.MixProject do def application do [ mod: {Hub.Application, []}, - extra_applications: [:logger, :runtime_tools] + extra_applications: [ + :logger, + :runtime_tools, + :ueberauth, + :ueberauth_github, + :ueberauth_twitter, + ] ] end @@ -42,7 +48,10 @@ defmodule Hub.MixProject do {:phoenix_live_reload, "~> 1.2", only: :dev}, {:gettext, "~> 0.11"}, {:jason, "~> 1.0"}, - {:plug_cowboy, "~> 2.0"} + {:plug_cowboy, "~> 2.0"}, + {:ueberauth, "~> 0.6"}, + {:ueberauth_github, "~> 0.7"}, + {:ueberauth_twitter, "~> 0.3"}, ] end diff --git a/mix.lock b/mix.lock index dfb15d5..60277b5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,7 @@ %{ + "argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "28ccb63bff213aecec1f7f3dde9648418b031f822499973281d8f494b9d5a3b3"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, @@ -6,10 +9,19 @@ "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b96cbb83a94713731461ea48521b178b0e3863d310a39a3948c807266eebd69"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, + "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, + "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, + "oauther": {:hex, :oauther, "1.1.1", "7d8b16167bb587ecbcddd3f8792beb9ec3e7b65c1f8ebd86b8dd25318d535752", [:mix], [], "hexpm", "9374f4302045321874cccdc57eb975893643bd69c3b22bf1312dab5f06e5788e"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "phoenix": {:hex, :phoenix, "1.4.14", "daad685c40579393463ecb87896aa816cdec8ab6fddea2b142f44c24b2414e38", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0495825477b9699a065d1f865d9f5d8711fa5ba09bf8b5b762b58549efc00fa"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"}, @@ -20,5 +32,11 @@ "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, + "ueberauth_github": {:hex, :ueberauth_github, "0.8.0", "2216c8cdacee0de6245b422fb397921b64a29416526985304e345dab6a799d17", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "b65ccc001a7b0719ba069452f3333d68891f4613ae787a340cce31e2a43307a3"}, + "ueberauth_identity": {:hex, :ueberauth_identity, "0.3.0", "bdd2697a69e4ced44f24809930d57d9d9a7834bbd1e7260947a1bbae42a4c2f9", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "f502f5974b52805449551177441207b25d8535a1812eb87e8c88afc73dce9a5a"}, + "ueberauth_twitter": {:hex, :ueberauth_twitter, "0.4.0", "4b98620341bc91bac90459093bba093c650823b6e2df35b70255c493c17e9227", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:oauther, "~> 1.1", [hex: :oauther, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "fb29c9047ca263038c0c61f5a0ec8597e8564aba3f2b4cb02704b60205fd4468"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, } diff --git a/priv/repo/migrations/20200301124142_create_users.exs b/priv/repo/migrations/20200301124142_create_users.exs index 5407536..764d5f3 100644 --- a/priv/repo/migrations/20200301124142_create_users.exs +++ b/priv/repo/migrations/20200301124142_create_users.exs @@ -3,12 +3,7 @@ defmodule Hub.Repo.Migrations.CreateUsers do def change do create table(:users) do - add :name, :string - add :username, :string - timestamps() end - - create unique_index(:users, [:username]) end end diff --git a/priv/repo/migrations/20200301130136_create_credentials.exs b/priv/repo/migrations/20200301130136_create_credentials.exs deleted file mode 100644 index 870a7d7..0000000 --- a/priv/repo/migrations/20200301130136_create_credentials.exs +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Hub.Repo.Migrations.CreateCredentials do - use Ecto.Migration - - def change do - create table(:credentials) do - add :email, :string - add :user_id, references(:users, on_delete: :delete_all), - null: false - - timestamps() - end - - create unique_index(:credentials, [:email]) - create index(:credentials, [:user_id]) - end -end diff --git a/priv/repo/migrations/20200310191318_create_authorizations.exs b/priv/repo/migrations/20200310191318_create_authorizations.exs new file mode 100644 index 0000000..02e657e --- /dev/null +++ b/priv/repo/migrations/20200310191318_create_authorizations.exs @@ -0,0 +1,20 @@ +defmodule Hub.Repo.Migrations.CreateAuthorizations do + use Ecto.Migration + + def change do + create table(:authorizations) do + add :provider, :string + add :uid, :string + add :user_id, references(:users, on_delete: :delete_all) + add :token, :text + add :refresh_token, :text + add :expires_at, :bigint + + timestamps() + end + + create index(:authorizations, [:provider, :uid], unique: true) + create index(:authorizations, [:expires_at]) + create index(:authorizations, [:provider, :token]) + end +end diff --git a/shell.nix b/shell.nix index ecbf874..73adbe7 100644 --- a/shell.nix +++ b/shell.nix @@ -15,6 +15,7 @@ mkShell { elmPackages.elm-test elmPackages.elm-format elmPackages.elm-doc-preview + elmPackages.elm-language-server elm2nix expect cabal2nix diff --git a/test/hub/accounts_test.exs b/test/hub/accounts_test.exs index 251b964..39e96ae 100644 --- a/test/hub/accounts_test.exs +++ b/test/hub/accounts_test.exs @@ -6,7 +6,7 @@ defmodule Hub.AccountsTest do describe "users" do alias Hub.Accounts.User - @valid_attrs %{name: "some name", username: "some username"} + @valid_attrs %{name: "some name", username: "some username", credential: %{email: "some email"}} @update_attrs %{name: "some updated name", username: "some updated username"} @invalid_attrs %{name: nil, username: nil}