Compare commits

...

30 Commits

Author SHA1 Message Date
Stian Aune 042130f11c Done? 5 years ago
Stian Aune 3089c6f8be Bugfix + some unit tests bc why not? 5 years ago
Stian Aune 38f874b553 Front page. 5 years ago
Stian Aune 5bdf2a0c48 Rename groups and stuff. 5 years ago
Stian Aune 3130710b19 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 118a21ad42 I can a lot of things. 5 years ago
Stian Aune 089ee68169 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 0a6cc16e0f More stuff. 5 years ago
Stian Aune 1ef2623637 Add/remove group. 5 years ago
Stian Aune 59240ce22d Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 537e86cced For reals. 5 years ago
Stian Aune de5fb0d16d Modal cleanup + light page. 5 years ago
Stian Aune 01daa28000 Hmm... 5 years ago
Stian Aune be2fe56e62 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 99867a5fd6 WIP. 5 years ago
Stian Aune d82bbf53f8 WIP. 5 years ago
Stian Aune 7ed6d2148d Stuffs. 5 years ago
Stian Aune ee9a3d485e Bleurgh. 5 years ago
Stian Aune 59ef68ad8f Thursday night commit. 5 years ago
Stian Aune ee3ff50342 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune daf071a442 Rewrote light tests. 5 years ago
Stian Aune edffa49d18 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 83052a2ab8 Blah. 5 years ago
Stian Aune aec6cd0b7d Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune e6fefb5bd1 Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 97f7a78f3f Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune 195738d6ed Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune a2f2c2b0bc Merge remote-tracking branch 'origin/master' into webui 5 years ago
Stian Aune edd629eb39 More stuff. 5 years ago
Stian Aune 206d5005db Cleanup. 5 years ago
  1. 1
      go.mod
  2. 7
      go.sum
  3. 53
      models/group_test.go
  4. 127
      models/light_test.go
  5. 73
      models/session_test.go
  6. 55
      models/user_test.go
  7. 2
      readme.md
  8. 68
      webui/README.md
  9. 836
      webui/package-lock.json
  10. 18
      webui/package.json
  11. 3
      webui/proxy.json
  12. 39
      webui/src/App.css
  13. 53
      webui/src/App.js
  14. 53
      webui/src/Components/Bridge.jsx
  15. 23
      webui/src/Components/BridgeLight.jsx
  16. 25
      webui/src/Components/Bridges.jsx
  17. 141
      webui/src/Components/Forms/LoginForm.jsx
  18. 101
      webui/src/Components/Group.jsx
  19. 27
      webui/src/Components/Groups.jsx
  20. 21
      webui/src/Components/Header.jsx
  21. 70
      webui/src/Components/LGroup.jsx
  22. 21
      webui/src/Components/LGroups.jsx
  23. 60
      webui/src/Components/Light.jsx
  24. 12
      webui/src/Components/Loading.jsx
  25. 42
      webui/src/Components/Misc/BrightnessSlider.jsx
  26. 40
      webui/src/Components/Misc/ColorPicker.jsx
  27. 94
      webui/src/Components/Modals/BridgeModal.jsx
  28. 39
      webui/src/Components/Modals/ColorModal.jsx
  29. 43
      webui/src/Components/Modals/GroupAddModal.jsx
  30. 103
      webui/src/Components/Modals/GroupPropertiesModal.jsx
  31. 64
      webui/src/Components/Modals/LightPropertiesModal.jsx
  32. 84
      webui/src/Components/Modals/PermissionsModal.jsx
  33. 18
      webui/src/Components/Pages/AdminPage.jsx
  34. 10
      webui/src/Components/Pages/GroupPage.jsx
  35. 10
      webui/src/Components/Pages/IndexPage.jsx
  36. 25
      webui/src/Components/Pages/LightPage.jsx
  37. 37
      webui/src/Components/Structure/Header.jsx
  38. 92
      webui/src/Helpers/fetcher.js
  39. 107
      webui/src/Helpers/groups.js
  40. 7
      webui/src/Helpers/keys.js
  41. 141
      webui/src/Helpers/lights.js
  42. 7
      webui/src/Helpers/null.js
  43. 3
      webui/src/Helpers/percentage.js
  44. 12
      webui/src/Helpers/random.js
  45. 91
      webui/src/Hooks/auth.js
  46. 99
      webui/src/Hooks/bridge.js
  47. 20
      webui/src/Hooks/group.js
  48. 24
      webui/src/Hooks/light.js
  49. 42
      webui/src/Hooks/lights.js
  50. 33
      webui/src/Reducers/authReducer.js
  51. 10
      webui/src/Reducers/index.js
  52. 10
      webui/src/index.js
  53. 15
      webui/src/setupProxy.js

1
go.mod

@ -8,6 +8,7 @@ require (
github.com/gorilla/mux v1.6.2
github.com/jmoiron/sqlx v1.2.0
github.com/mattn/go-sqlite3 v1.10.0
github.com/stretchr/testify v1.3.0
golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4
google.golang.org/appengine v1.4.0 // indirect

7
go.sum

@ -2,6 +2,8 @@ github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 h1:NOPuu1sMqBVC3iyl
github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:ex2AEcUvgoeoE/uknn0ZUExwGhBcH9jwA5heqv2xq6w=
github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8 h1:Qhd31xZ6GUL0nEaXYP4nXOn8J6l9jqa6xEyp70qfjZE=
github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:HFm7vkh/1EJQ9ymYsKUQtK7JlG3om1r61wMAHtl+bxw=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -18,6 +20,11 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b h1:Elez2XeF2p9uyVj0yEUDqQ56NFcDtcBNkYP7yv8YbUE=
golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

53
models/group_test.go

@ -0,0 +1,53 @@
package models_test
import (
"git.aiterp.net/lucifer/lucifer/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestGroup_Permission_NotExisting(t *testing.T) {
group := models.Group{
ID: 1001,
Name: "Illuminati",
Permissions: []models.GroupPermission{},
}
perm := group.Permission(2001)
assert.Equal(t, 1001, perm.GroupID)
assert.Equal(t, 2001, perm.UserID)
assert.False(t, perm.Read)
assert.False(t, perm.Write)
assert.False(t, perm.Create)
assert.False(t, perm.Delete)
assert.False(t, perm.Manage)
}
func TestGroup_Permission_Existing(t *testing.T) {
group := models.Group{
ID: 1002,
Name: "The Not-So-Dark Side",
Permissions: []models.GroupPermission{
{
GroupID: 1002,
UserID: 2002,
Read: true,
Write: true,
Create: false,
Delete: false,
Manage: false,
},
},
}
perm := group.Permission(2002)
assert.Equal(t, 1002, perm.GroupID)
assert.Equal(t, 2002, perm.UserID)
assert.True(t, perm.Read)
assert.True(t, perm.Write)
assert.False(t, perm.Create)
assert.False(t, perm.Delete)
assert.False(t, perm.Manage)
}

127
models/light_test.go

@ -0,0 +1,127 @@
package models_test
import (
"git.aiterp.net/lucifer/lucifer/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestLight_SetColor_LoppingOffHash(t *testing.T) {
light := models.Light{}
err := light.SetColor("#FFB34F")
assert.Nil(t, err)
assert.Equal(t, "FFB34F", light.Color)
}
func TestLight_SetColor_InvalidLength(t *testing.T) {
light := models.Light{}
err := light.SetColor("FFB34F32")
assert.NotNil(t, err)
}
func TestLight_SetColor_InvalidHex(t *testing.T) {
light := models.Light{}
err := light.SetColor("FFB34G")
assert.NotNil(t, err)
}
func TestLight_SetColorRGB(t *testing.T) {
light := models.Light{}
light.SetColorRGB(32, 55, 100)
assert.Equal(t, "203764", light.Color)
}
func TestLight_SetColorRGBf(t *testing.T) {
light := models.Light{}
light.SetColorRGBf(0.1, 0.2, 0.3)
assert.Equal(t, "19334c", light.Color)
}
func TestLight_ColorRGB_Empty(t *testing.T) {
light := models.Light{}
light.Color = ""
r, g, b, err := light.ColorRGB()
assert.Equal(t, uint8(0), r)
assert.Equal(t, uint8(0), g)
assert.Equal(t, uint8(0), b)
assert.Nil(t, err)
}
func TestLight_ColorRGB_Invalid(t *testing.T) {
light := models.Light{}
light.Color = "Utterly bonkers"
_, _, _, err := light.ColorRGB()
assert.NotNil(t, err)
assert.Equal(t, models.ErrMalformedColor, err)
}
func TestLight_ColorRGB_TooShort(t *testing.T) {
light := models.Light{}
light.Color = "3002"
_, _, _, err := light.ColorRGB()
assert.NotNil(t, err)
assert.Equal(t, models.ErrMalformedColor, err)
}
func TestLight_ColorRGB_TooLong(t *testing.T) {
light := models.Light{}
light.Color = "3002DB05"
_, _, _, err := light.ColorRGB()
assert.NotNil(t, err)
assert.Equal(t, models.ErrMalformedColor, err)
}
func TestLight_ColorRGB_Valid(t *testing.T) {
light := models.Light{}
light.Color = "203764"
r, g, b, err := light.ColorRGB()
assert.Equal(t, uint8(32), r)
assert.Equal(t, uint8(55), g)
assert.Equal(t, uint8(100), b)
assert.Nil(t, err)
}
func TestLight_ColorRGBf_Invalid(t *testing.T) {
light := models.Light{}
light.Color = "G0I1J2"
r, g, b, err := light.ColorRGBf()
assert.Equal(t, float64(0), r)
assert.Equal(t, float64(0), g)
assert.Equal(t, float64(0), b)
assert.NotNil(t, err)
}
func TestLight_ColorRGBf_Valid(t *testing.T) {
light := models.Light{}
light.Color = "19334C"
r, g, b, err := light.ColorRGBf()
// Within half a percent
assert.InDelta(t, float64(0.1), r, 0.005)
assert.InDelta(t, float64(0.2), g, 0.005)
assert.InDelta(t, float64(0.3), b, 0.005)
assert.Nil(t, err)
}

73
models/session_test.go

@ -0,0 +1,73 @@
package models_test
import (
"context"
"git.aiterp.net/lucifer/lucifer/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSession_GenerateID_Works(t *testing.T) {
session := &models.Session{}
err := session.GenerateID()
assert.Nil(t, err)
}
func TestSession_Cookie_IsCorrect(t *testing.T) {
sessID := "testId"
expiry := time.Now().AddDate(0, 0, 2)
session := &models.Session{
ID: sessID,
UserID: 0,
Expires: expiry,
}
cookie := session.Cookie()
assert.Equal(t, sessID, cookie.Value)
assert.Equal(t, expiry, cookie.Expires)
}
func TestSession_Expired_BeforeExpiry(t *testing.T) {
session := &models.Session{
ID: "12345",
UserID: 0,
Expires: time.Now().AddDate(0, 0, 1),
}
assert.False(t, session.Expired())
}
func TestSession_Expired_AfterExpiry(t *testing.T) {
session := &models.Session{
ID: "12345",
UserID: 0,
Expires: time.Now().AddDate(0, 0, -1),
}
assert.True(t, session.Expired())
}
func TestSessionFromContext_InContext(t *testing.T) {
session := &models.Session{
ID: "12345",
UserID: 0,
Expires: time.Now().AddDate(0, 0, 1),
}
ctx := session.InContext(context.Background())
session2 := models.SessionFromContext(ctx)
assert.Equal(t, session2, session)
}
func TestSessionFromContext_Empty(t *testing.T) {
session := models.SessionFromContext(context.Background())
assert.Nil(t, session)
}

55
models/user_test.go

@ -0,0 +1,55 @@
package models_test
import (
"context"
"git.aiterp.net/lucifer/lucifer/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestUser_SetPassword_TooShort(t *testing.T) {
user := &models.User{}
err := user.SetPassword("short")
assert.NotNil(t, err)
}
func TestUser_SetPassword_Valid(t *testing.T) {
user := &models.User{}
err := user.SetPassword("longer")
assert.Nil(t, err)
assert.NotEqual(t, "", user.PassHash)
}
func TestUser_CheckPassword_Wrong(t *testing.T) {
user := &models.User{}
_ = user.SetPassword("longer")
err := user.CheckPassword("wrong attempt")
assert.NotNil(t, err)
}
func TestUser_CheckPassword_Correct(t *testing.T) {
user := &models.User{}
_ = user.SetPassword("correct")
err := user.CheckPassword("correct")
assert.Nil(t, err)
}
func TestUserFromContext(t *testing.T) {
nilUser := models.UserFromContext(context.Background())
assert.Nil(t, nilUser)
user := &models.User{ID: 1001, Name: "Freddie"}
ctx := user.InContext(context.Background())
assert.Equal(t, user, models.UserFromContext(ctx))
}

2
readme.md

@ -6,4 +6,4 @@ Lucifer is a web interface for controlling Phillips Hue bulbs and providing acce
* `cmd/<name>`: The CLI entry points.
* `models`: The models and their repository interfaces.
* `database/<dbtype>`: Repository implementations for the various models.
* `react/`: React client.
* `webui/`: React client.

68
webui/README.md

@ -1,68 +0,0 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
### Analyzing the Bundle Size
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
### Making a Progressive Web App
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
### Advanced Configuration
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
### Deployment
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
### `npm run build` fails to minify
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify

836
webui/package-lock.json
File diff suppressed because it is too large
View File

18
webui/package.json

@ -3,13 +3,21 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@jaames/iro": "^4.0.1",
"bootstrap": "^4.2.1",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-redux": "^6.0.0",
"react": "^16.8.1",
"react-dom": "^16.8.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-scripts": "2.1.3",
"reactstrap": "^7.0.2",
"redux": "^4.0.1"
"reactn": "^0.2.2",
"reactstrap": "^7.1.0"
},
"devDependencies": {
"@types/react": "^16.8.1",
"@types/reactstrap": "^7.1.0",
"http-proxy-middleware": "latest",
"@types/node": "^11.9.0"
},
"scripts": {
"start": "react-scripts start",

3
webui/proxy.json

@ -0,0 +1,3 @@
{
"url": "http://10.32.7.1:8000/"
}

39
webui/src/App.css

@ -1,32 +1,25 @@
.App {
text-align: center;
.nav-tabs .nav-item {
cursor: pointer;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 40vmin;
a, button {
cursor: pointer;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
.badge {
cursor: pointer;
}
.App-link {
color: #61dafb;
.loading {
margin: 2em 0;
text-align: center;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.on-switch {
text-align: center;
}
.free-buttons {
margin-top: 2em;
text-align: center;
}

53
webui/src/App.js

@ -1,15 +1,46 @@
import React, {Component} from 'react';
import React, {useEffect, useState} from 'react';
import './App.css';
import Header from "./Components/Header";
import Header from "./Components/Structure/Header";
import LoginForm from "./Components/Forms/LoginForm";
import {Container} from "reactstrap";
import {BrowserRouter} from "react-router-dom";
import {Route} from "react-router";
import useAuth from "./Hooks/auth";
import IndexPage from "./Components/Pages/IndexPage";
import GroupPage from "./Components/Pages/GroupPage";
import Loading from "./Components/Loading";
import LightPage from "./Components/Pages/LightPage";
import AdminPage from "./Components/Pages/AdminPage";
class App extends Component {
render() {
return (
<div>
export default function App() {
const [hasStarted, setHasStarted] = useState(false);
const {isLoggedIn, isChecked, verify} = useAuth();
useEffect(() => {
if (!hasStarted) {
verify();
setHasStarted(true);
}
});
return (
<BrowserRouter>
<>
<Header/>
</div>
);
}
<Container>
{!isChecked && <Loading/>}
{isChecked && !isLoggedIn && <LoginForm/>}
{isChecked && isLoggedIn && (
<>
<Route exact path="/" component={IndexPage}/>
<Route exact path="/lights" component={LightPage}/>
<Route exact path="/groups" component={GroupPage}/>
<Route exact path="/admin" component={AdminPage}/>
</>
)}
</Container>
</>
</BrowserRouter>
);
}
export default App;

53
webui/src/Components/Bridge.jsx

@ -0,0 +1,53 @@
import React, {useState} from "react";
import {Button, Card, CardBody, CardFooter, CardHeader} from "reactstrap";
import useBridges from "../Hooks/bridge";
import BridgeModal from "./Modals/BridgeModal";
import useLights from "../Hooks/lights";
import Loading from "./Loading";
import BridgeLight from "./BridgeLight";
function Bridge({addr, driver, id, name}) {
const {forgetBridge, discoverLights} = useBridges();
const {lightsByBridge} = useLights();
const [modal, setModal] = useState(false);
function edit() {
setModal(true);
}
function unEdit() {
setModal(false);
}
function forget() {
if (window.confirm(`Vil du virkelig glemme bruen "${name}"?`)) {
forgetBridge(id);
}
}
function discover() {
discoverLights(id);
}
const lights = lightsByBridge(id);
const hasLights = lights !== null;
return (
<Card className="mt-3">
<CardHeader>{name} ({driver}, {addr})</CardHeader>
<CardBody>
{hasLights ? lights.map(l => <BridgeLight key={l.id} {...l} />) : <Loading/>}
</CardBody>
<CardFooter>
<Button color="info" onClick={discover}>Oppdag lys</Button>
{" "}
<Button color="secondary" onClick={edit}>Endre</Button>
{" "}
<Button color="danger" onClick={forget}>Glem</Button>
</CardFooter>
{modal && <BridgeModal bridge={{addr, driver, id, name}} onCancel={unEdit} />}
</Card>
);
}
export default Bridge;

23
webui/src/Components/BridgeLight.jsx

@ -0,0 +1,23 @@
import React, {useState} from "react";
import {Button, FormGroup} from "reactstrap";
import useBridges from "../Hooks/bridge";
function BridgeLight({ id, bridgeId, name }) {
const [waiting, setWaiting] = useState(false);
const {forgetLight} = useBridges();
function forget() {
setWaiting(true);
forgetLight(bridgeId, id);
}
return (
<FormGroup>
<Button color="danger" size="sm" disabled={waiting} onClick={forget}>Glem</Button>
{" "}
{name}
</FormGroup>
);
}
export default BridgeLight;

25
webui/src/Components/Bridges.jsx

@ -0,0 +1,25 @@
import React, {useState} from "react";
import useBridges from "../Hooks/bridge";
import Bridge from "./Bridge";
import {Button, FormGroup} from "reactstrap";
import BridgeModal from "./Modals/BridgeModal";
function Bridges() {
const [modal, setModal] = useState(false);
const {bridges} = useBridges();
if (bridges === null) {
return <></>;
}
return (
<div>
{bridges.map(bridge => <Bridge key={bridge.id} {...bridge} />)}
<FormGroup className="free-buttons">
<Button color="success" onClick={() => setModal(true)} >Søk etter bru</Button>
</FormGroup>
{modal && <BridgeModal onCancel={() => setModal(false)}/> }
</div>
);
}
export default Bridges;

141
webui/src/Components/Forms/LoginForm.jsx

@ -0,0 +1,141 @@
import React, {useState} from "react";
import {
Button,
Card,
CardBody,
CardFooter,
CardHeader,
Col,
Form,
FormGroup,
Input,
Label,
Nav,
NavItem,
NavLink,
TabPane
} from "reactstrap";
import TabContent from "reactstrap/es/TabContent";
import useAuth from "../../Hooks/auth";
import {onEnter} from "../../Helpers/keys";
export default function LoginForm() {
const {login, register} = useAuth();
const [tab, setTab] = useState(1);
const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState("");
const [regUser, setRegUser] = useState("");
const [regPass1, setRegPass1] = useState("");
const [regPass2, setRegPass2] = useState("");
return (
<Card className="mt-3">
<CardHeader>Tilgangskontroll</CardHeader>
<CardBody>
<Nav tabs>
<NavItem>
<NavLink className={tab === 1 ? "active" : ""}
onClick={() => setTab(1)}
onKeyDown={onEnter(() => setTab(1))}
tabIndex={0}
>
Innlogging
</NavLink>
</NavItem>
<NavItem>
<NavLink className={tab === 2 ? "active" : ""}
onClick={() => setTab(2)}
onKeyDown={onEnter(() => setTab(2))}
tabIndex={0}
>
Registrering
</NavLink>
</NavItem>
</Nav>
<TabContent activeTab={tab} className="mt-3">
<TabPane tabId={1}>
<Form>
<FormGroup row>
<Label sm={3} for="input-login-user">Brukernavn</Label>
<Col sm={9}>
<Input type="text"
id="input-login-user"
value={loginUser}
onChange={e => setLoginUser(e.target.value)}
/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3} for="input-login-password">Passord</Label>
<Col sm={9}>
<Input type="password"
id="input-login-password"
value={loginPass}
onChange={e => setLoginPass(e.target.value)}
/>
</Col>
</FormGroup>
</Form>
</TabPane>
<TabPane tabId={2}>
<Form>
<FormGroup row>
<Label sm={3} for="input-reg-user">Brukernavn</Label>
<Col sm={9}>
<Input type="text"
id="input-reg-user"
value={regUser}
onChange={e => setRegUser(e.target.value)}
/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3} for="input-reg-password-1">Passord</Label>
<Col sm={9}>
<Input type="password"
id="input-reg-password-1"
value={regPass1}
onChange={e => setRegPass1(e.target.value)}
/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3} for="input-reg-password-2">Gjenta passord</Label>
<Col sm={9}>
<Input type="password"
id="input-reg-password-2"
value={regPass2}
onChange={e => setRegPass2(e.target.value)}
/>
</Col>
</FormGroup>
</Form>
</TabPane>
</TabContent>
</CardBody>
<CardFooter>
{tab === 1 && <Button color="primary" onClick={() => login(loginUser, loginPass)}>Logg inn</Button>}
{tab === 2 && (
<>
<Button color="primary"
onClick={() => register(regUser, regPass1, regPass2)}
>
Registrer
</Button>
{" "}
<Button color="secondary"
onClick={() => {
setRegUser("");
setRegPass1("");
setRegPass2("");
setTab(1);
}}>
Avbryt
</Button>
</>
)}
</CardFooter>
</Card>
);
}

101
webui/src/Components/Group.jsx

@ -0,0 +1,101 @@
import React, {useState} from "react";
import {Button, Card, CardBody, CardFooter, CardHeader, ListGroup} from "reactstrap";
import useLights from "../Hooks/light";
import Light from "./Light";
import Loading from "./Loading";
import {deleteGroup} from "../Helpers/groups";
import ColorModal from "./Modals/ColorModal";
import {changeColor} from "../Helpers/lights";
import GroupPropertiesModal from "./Modals/GroupPropertiesModal";
import useAuth from "../Hooks/auth";
function Group({id, name, permissions}) {
const {user} = useAuth();
const lights = useLights({groupId: id});
const [colorModal, setColorModal] = useState(false);
const [propModal, setPropModal] = useState(false);
const ready = lights !== null;
const noLights = ready && lights.length === 0;
const hasLights = ready && lights.length > 0;
if (id === 0 && noLights) {
return <></>;
}
function onDelete() {
if (window.confirm(`Vil du virkelig fjerne "${name}"?`)) {
deleteGroup(id);
}
}
let cValue = null;
let bValue = null;
let pValue = null;
if (hasLights) {
cValue = lights[0].color;
bValue = lights[0].brightness;
pValue = lights[0].on;
}
function iCan(name) {
if (permissions === null) {
return false;
}
const perm = permissions.find(p => p.userId === user.id);
return typeof perm !== "undefined"
? perm[name]
: false;
}
return (
<Card className="mt-3">
<CardHeader>{name}</CardHeader>
<CardBody>
{ready
? <ListGroup>{lights.map(light => <Light key={light.id} {...light} />)}</ListGroup>
: <Loading/>}
</CardBody>
{(iCan("manage") || iCan("write") || iCan("delete")) && (
<CardFooter>
{iCan("manage") && (
<Button color="primary" onClick={() => setPropModal(true)}>Detaljer</Button>
)}
{" "}
{hasLights && iCan("write") && (
<Button color="secondary" onClick={() => setColorModal(true)}>Skift farger</Button>
)}
{" "}
{iCan("delete") && (id > 0) && (
<Button color="danger" onClick={onDelete}>Fjern</Button>
)}
</CardFooter>
)}
{colorModal && (
<ColorModal cValue={cValue}
bValue={bValue}
pValue={pValue}
onConfirm={(color, brightness, power) => {
lights.forEach(light => {
changeColor(light.id, color, brightness, power);
setColorModal(false);
});
}}
onCancel={() => setColorModal(false)}
/>
)}
{propModal && (
<GroupPropertiesModal id={id}
nValue={name}
permissions={permissions}
onClose={() => setPropModal(false)}
/>
)}
</Card>
);
}
export default Group;

27
webui/src/Components/Groups.jsx

@ -0,0 +1,27 @@
import React, {useState} from "react";
import useGroups from "../Hooks/group";
import Group from "./Group";
import Loading from "./Loading";
import GroupAddModal from "./Modals/GroupAddModal";
import {Button, FormGroup} from "reactstrap";
function Groups() {
const groups = useGroups();
const [addModal, setAddModal] = useState(false);
if (groups === null) {
return <Loading/>;
}
return (
<div>
{groups.map(group => <Group key={group.id} {...group} />)}
<FormGroup className="free-buttons">
<Button color="success" onClick={() => setAddModal(true)} >Ny gruppe</Button>
</FormGroup>
{addModal && <GroupAddModal onClose={() => setAddModal(false)}/>}
</div>
);
}
export default Groups;

21
webui/src/Components/Header.jsx

@ -1,21 +0,0 @@
import React, {Component} from "react"
import {connect} from "react-redux";
import {Navbar, NavbarBrand} from "reactstrap";
class Header extends Component {
constructor(props) {
super(props);
}
render() {
return (
<Navbar color="dark" expand="md">
<NavbarBrand href="/">Lucifer</NavbarBrand>
</Navbar>
);
}
}
const mapStateToProps = (state) => ({});
export default connect(mapStateToProps)(Header);

70
webui/src/Components/LGroup.jsx

@ -0,0 +1,70 @@
import React, {useEffect, useState} from "react";
import useLights from "../Hooks/light";
import {Card, CardBody, CardHeader, Col, CustomInput, FormGroup} from "reactstrap";
import ColorPicker from "./Misc/ColorPicker";
import {changeColor} from "../Helpers/lights";
import BrightnessSlider from "./Misc/BrightnessSlider";
function LGroup({id, name}) {
const lights = useLights({groupId: id});
const light = lights !== null && lights.length > 0 ? lights[0] : null;
const [color, setColor] = useState(null);
const [brightness, setBrightness] = useState(null);
const [power, setPower] = useState(null);
useEffect(() => {
if (light !== null) {
setColor(light.color);
setBrightness(light.brightness);
setPower(light.on);
}
}, [light]);
if (light === null || color === null || brightness === null || power === null) {
return <></>;
}
function recolor(newColor) {
setColor(newColor);
lights.forEach(l => changeColor(l.id, newColor, null, null));
}
function rebrightness(newBrightness) {
setBrightness(newBrightness);
lights.forEach(l => changeColor(l.id, null, newBrightness, null));
}
function repower(newPower) {
setPower(newPower);
lights.forEach(l => changeColor(l.id, null, null, newPower));
}
return (
<Col lg={4} md={6} sm={12}>
<Card className="mt-3">
<CardHeader>
{name}
</CardHeader>
<CardBody>
<FormGroup>
{power && <ColorPicker color={color} onChange={recolor}/>}
</FormGroup>
<FormGroup>
{power && <BrightnessSlider brightness={brightness} onChange={rebrightness}/>}
</FormGroup>
<FormGroup className="on-switch">
<CustomInput type="switch"
id={`switch-power-${id}`}
name="power"
label={power ? "På" : "Av"}
checked={power}
onChange={e => repower(e.target.checked)}/>
</FormGroup>
</CardBody>
</Card>
</Col>
);
}
export default LGroup;

21
webui/src/Components/LGroups.jsx

@ -0,0 +1,21 @@
import React from "react";
import useGroups from "../Hooks/group";
import Loading from "./Groups";
import LGroup from "./LGroup";
import {Row} from "reactstrap";
function LGroups() {
const groups = useGroups();
if (groups === null) {
return <Loading/>;
}
return (
<Row>
{groups.map(group => <LGroup key={group.id} {...group} />)}
</Row>
);
}
export default LGroups;

60
webui/src/Components/Light.jsx

@ -0,0 +1,60 @@
import React, {useState} from "react";
import {Badge, ButtonDropdown, Col, DropdownItem, DropdownMenu, DropdownToggle, ListGroupItem, Row} from "reactstrap";
import ColorModal from "./Modals/ColorModal";
import {changeColor} from "../Helpers/lights";
import LightPropertiesModal from "./Modals/LightPropertiesModal";
function Light({id, groupId, name, on, color, brightness}) {
const [modal, setModal] = useState(false);
const [dropDown, setDropDown] = useState(false);
const [propModal, setPropModal] = useState(false);
return (
<ListGroupItem>
<Row>
<Col xs={7}>
<ButtonDropdown isOpen={dropDown} toggle={() => setDropDown(!dropDown)}>
<DropdownToggle size="sm" caret outline color="secondary">
{name}
</DropdownToggle>
<DropdownMenu>
<DropdownItem onClick={() => setPropModal(true)}>Egenskaper</DropdownItem>
<DropdownItem>Glem</DropdownItem>
</DropdownMenu>
</ButtonDropdown>
</Col>
<Col xs={3}>
{on && (
<Badge style={{backgroundColor: `#${color}`}} onClick={() => setModal(true)}>
{color.toUpperCase()}
</Badge>
)}
</Col>
<Col xs={2}>
<Badge style={{backgroundColor: `gray(${brightness})`}} onClick={() => setModal(true)}>
{on ? brightness : "Av"}
</Badge>
</Col>
</Row>
{modal && (
<ColorModal cValue={color}
bValue={brightness}
pValue={on}
onConfirm={(newColor, newBrightness, newPower) => {
changeColor(id, newColor, newBrightness, newPower);
setModal(false);
}}
onCancel={() => setModal(false)}
/>
)}
{propModal && (
<LightPropertiesModal id={id}
gValue={groupId}
nValue={name}
onClose={() => setPropModal(false)}/>
)}
</ListGroupItem>
);
}
export default Light;

12
webui/src/Components/Loading.jsx

@ -0,0 +1,12 @@
import React from "react";
import {Spinner} from "reactstrap";
function Loading() {
return (
<div className="loading">
<Spinner color="warning" style={{width: "4rem", height: "4rem"}}/>
</div>
);
}
export default Loading;

42
webui/src/Components/Misc/BrightnessSlider.jsx

@ -0,0 +1,42 @@
import React, {useLayoutEffect, useState} from "react";
import {randId} from "../../Helpers/random";
import iro from "@jaames/iro";
function BrightnessSlider({brightness, onChange}) {
const [random] = useState(randId());
useLayoutEffect(() => {
const colorPicker = new iro.ColorPicker("#brightness-picker-" + random, {
color: `rgb(${brightness}, ${brightness}, ${brightness})`,
layout: [
{
component: iro.ui.Slider,
options: {
borderColor: '#888'
}
}
],
});
colorPicker.on("input:end", color => {
onChange(color.rgb.r);
});
return () => {
const elem = document.getElementById(`brightness-picker-${random}`);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [brightness]);
return (
<div id={`brightness-picker-${random}`}>
</div>
);
}
export default BrightnessSlider;

40
webui/src/Components/Misc/ColorPicker.jsx

@ -0,0 +1,40 @@
import React, {useLayoutEffect, useState} from "react";
import iro from '@jaames/iro';
import {randId} from "../../Helpers/random";
function ColorPicker({color, onChange}) {
const [random] = useState(randId());
useLayoutEffect(() => {
const colorPicker = new iro.ColorPicker("#color-picker-" + random, {
color: `#${color}`,
layout: [
{
component: iro.ui.Wheel,
options: {}
}
],
});
colorPicker.on("input:end", color => {
onChange(color.hexString.substr(1));
});
return () => {
const elem = document.getElementById(`color-picker-${random}`);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [color]);
return (
<div id={`color-picker-${random}`}>
</div>
);
}
export default ColorPicker;

94
webui/src/Components/Modals/BridgeModal.jsx

@ -0,0 +1,94 @@
import React, {useState} from "react";
import {Button, Col, Form, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";
import useBridges from "../../Hooks/bridge";
import Loading from "../Loading";
function BridgeModal({onCancel, bridge = null}) {
const {addBridge, editBridge} = useBridges();
const edit = bridge !== null;
const [name, setName] = useState(edit ? bridge.name : "");
const [driver, setDriver] = useState(edit ? bridge.driver : "");
const [addr, setAddr] = useState(edit ? bridge.addr : "");
const [waiting, setWaiting] = useState(false);
function onConfirm() {
if (edit) {
editBridge(bridge.id, name);
} else {
setWaiting(true);
addBridge(name, driver, addr, (result) => {
if (!result) {
alert("Noe gikk galt");
}
setWaiting(false);
onCancel();
});
}
}
return (
<Modal isOpen={true}>
<ModalHeader>{edit ? "Bruegenskaper" : "Ny bru"}</ModalHeader>
<ModalBody>
{waiting ? (
<Loading/>
) : (
<Form>
<FormGroup row>
<Label sm={3} for="text-name">
Navn:
</Label>
<Col sm={9}>
<Input type="text"
id="text-name"
name="name"
value={name}
onChange={e => setName(e.target.value)}
/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3} for="text-driver">
Driver:
</Label>
<Col sm={9}>
<Input type="text"
id="text-driver"
name="driver"
value={driver}
onChange={e => setDriver(e.target.value)}
disabled={edit}
/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3} for="text-name">
Adresse:
</Label>
<Col sm={9}>
<Input type="text"
id="text-addr"
name="addr"
value={addr}
onChange={e => setAddr(e.target.value)}
disabled={edit}
/>
</Col>
</FormGroup>
</Form>
)}
</ModalBody>
<ModalFooter>
<Button color="primary" disabled={waiting} onClick={onConfirm}>Bekreft</Button>
{" "}
<Button color="secondary" disabled={waiting} onClick={onCancel}>Avbryt</Button>
</ModalFooter>
</Modal>
);
}
export default BridgeModal;

39
webui/src/Components/Modals/ColorModal.jsx

@ -0,0 +1,39 @@
import React, {useState} from "react";
import {Button, CustomInput, FormGroup, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";
import ColorPicker from "../Misc/ColorPicker";
import BrightnessSlider from "../Misc/BrightnessSlider";
function ColorModal({cValue, bValue, pValue, onConfirm, onCancel}) {
const [color, setColor] = useState(cValue);
const [brightness, setBrightness] = useState(bValue);
const [power, setPower] = useState(pValue);
return (
<Modal isOpen={true}>
<ModalHeader>Fargevalg</ModalHeader>
<ModalBody style={{margin: "0 auto"}}>
<FormGroup>
<ColorPicker color={color} onChange={setColor}/>
</FormGroup>
<FormGroup>
<BrightnessSlider brightness={brightness} onChange={setBrightness}/>
</FormGroup>
<FormGroup className="on-switch">
<CustomInput type="switch"
id="switch-power"
name="power"
label={power ? "På" : "Av"}
checked={power}
onChange={e => setPower(e.target.checked)}/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="primary" onClick={() => onConfirm(color, brightness, power)}>Lagre</Button>
{" "}
<Button color="secondary" onClick={onCancel}>Avbryt</Button>
</ModalFooter>
</Modal>
);
}
export default ColorModal;

43
webui/src/Components/Modals/GroupAddModal.jsx

@ -0,0 +1,43 @@
import React, {useState} from "react";
import {Button, Col, Form, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";
import {addGroup} from "../../Helpers/groups";
function GroupAddModal({onClose}) {
const [name, setName] = useState("");
return (
<Modal isOpen={true}>
<ModalHeader>Ny gruppe</ModalHeader>
<ModalBody style={{margin: "0 auto"}}>
<Form>
<FormGroup row>
<Label sm={3} for="text-name">
Navn:
</Label>
<Col sm={9}>
<Input type="text"
id="text-name"
name="power"
value={name}
onChange={e => setName(e.target.value)}/>
</Col>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button color="primary"
onClick={() => {
addGroup(name);
onClose();
}}
>
Lagre
</Button>
{" "}
<Button color="secondary" onClick={onClose}>Avbryt</Button>
</ModalFooter>
</Modal>
);
}
export default GroupAddModal;

103
webui/src/Components/Modals/GroupPropertiesModal.jsx

@ -0,0 +1,103 @@
import React, {useState} from "react";
import useAuth from "../../Hooks/auth";
import {
Button,
Col,
Form,
FormGroup,
Input,
InputGroup,
InputGroupAddon,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Row
} from "reactstrap";
import PermissionsModal from "./PermissionsModal";
import {saveGroupMetadata} from "../../Helpers/groups";
function GroupPropertiesModal({id, nValue, permissions, onClose}) {
const {users} = useAuth();
const [name, setName] = useState(nValue);
const [permUserId, setPermUserId] = useState(null);
const [permModal, setPermModal] = useState(false);
function permissionFor(userId) {
const perm = permissions.find(p => p.userId === userId);
return typeof perm !== "undefined"
? perm
: null;
}
return (
<Modal isOpen={true}>
<ModalHeader>Endre gruppe</ModalHeader>
<ModalBody style={{margin: "0 auto"}}>
<Form>
<FormGroup row>
<Label sm={3} for="text-name">
Navn:
</Label>
<Col sm={9}>
<Input type="text"
id="text-name"
name="power"
value={name}
onChange={e => setName(e.target.value)}/>
</Col>
</FormGroup>
<FormGroup row>
<Label sm={3}>
Tilgang:
</Label>
<Col sm={9}>
{users.map(user => {
const permission = permissionFor(user.id);
const btnColor = permission !== null ? "secondary" : "success";
const btnLabel = permission !== null ? "Endre" : "Opprett";
return (
<Row>
<InputGroup className="mb-2">
<Input className="permission" disabled value={user.name}/>
<InputGroupAddon addonType="prepend">
<Button color={btnColor} onClick={() => {
setPermUserId(user.id);
setPermModal(true);
}}>{btnLabel}</Button>
</InputGroupAddon>
</InputGroup>
</Row>
)
})}
</Col>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button color="primary"
onClick={() => {
saveGroupMetadata(id, name);
onClose();
}}
>
Lagre
</Button>
{" "}
<Button color="secondary" onClick={onClose}>Avbryt</Button>
</ModalFooter>
{permModal && (
<PermissionsModal permissions={permissionFor(permUserId)}
groupId={id}
userId={permUserId}
onClose={() => setPermModal(false)}
/>
)}
</Modal>
);
}
export default GroupPropertiesModal;

64
webui/src/Components/Modals/LightPropertiesModal.jsx

@ -0,0 +1,64 @@
import React, {useState} from "react";
import {Button, Col, Form, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";
import useGroups from "../../Hooks/group";
import Loading from "../Loading";
import {changeLight} from "../../Helpers/lights";
function LightPropertiesModal({id, gValue, nValue, onClose}) {
const [groupId, setGroupId] = useState(gValue);
const [name, setName] = useState(nValue);
const groups = useGroups();
if (groups === null) {
return <Modal isOpen={true}><Loading/></Modal>;
}
return (
<Modal isOpen={true}>
<ModalHeader>Lysegenskaper</ModalHeader>
<ModalBody style={{margin: "0 auto"}}>
<Form>
<FormGroup row>
<Label sm={3} for="text-name">
Navn:
</Label>
<Col sm={9}>
<Input type="text"
id="text-name"
value={name}
onChange={e => setName(e.target.value)}
/>
</Col>
</FormGroup>
</Form>
<Form>
<FormGroup row>
<Label sm={3} for="sel-group">
Gruppe:
</Label>
<Col sm={9}>
<Input type="select"
value={groupId}
onChange={e => setGroupId(parseInt(e.target.value, 10))}>
{groups.map(g => <option value={g.id}>{g.name}</option>)}
</Input>
</Col>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button color="primary"
onClick={() => {
changeLight(id, name, groupId);
onClose();
}}
>
Lagre
</Button>
{" "}
<Button color="secondary" onClick={onClose}>Avbryt</Button>
</ModalFooter>
</Modal>
);
}
export default LightPropertiesModal;

84
webui/src/Components/Modals/PermissionsModal.jsx

@ -0,0 +1,84 @@
import React, {useState} from "react";
import {Button, CustomInput, FormGroup, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";
import {savePermissions} from "../../Helpers/groups";
function PermissionsModal({groupId, userId, permissions, onClose}) {
function getPermission(type) {
if (permissions === null || typeof permissions[type] === "undefined") {
return false;
}
return permissions[type];
}
const [read, setRead] = useState(getPermission("read"));
const [write, setWrite] = useState(getPermission("write"));
const [create, setCreate] = useState(getPermission("create"));
const [remove, setRemove] = useState(getPermission("delete"));
const manage = getPermission("manage");
return (
<Modal isOpen={true}>
<ModalHeader>Endre gruppe</ModalHeader>
<ModalBody style={{margin: "0 auto"}}>
<FormGroup className="perm-switch">
<CustomInput type="switch"
id="switch-read"
name="read"
label="Les"
checked={read}
onChange={e => setRead(e.target.checked)}/>
</FormGroup>
<FormGroup className="perm-switch">
<CustomInput type="switch"
id="switch-write"
name="write"
label="Skriv"
checked={write}
onChange={e => setWrite(e.target.checked)}/>
</FormGroup>
<FormGroup className="perm-switch">
<CustomInput type="switch"
id="switch-create"
name="create"
label="Opprett"
checked={create}
onChange={e => setCreate(e.target.checked)}/>
</FormGroup>
<FormGroup className="perm-switch">
<CustomInput type="switch"
id="switch-remove"
name="remove"
label="Slett"
checked={remove}
onChange={e => setRemove(e.target.checked)}/>
</FormGroup>
<FormGroup className="perm-switch">
<CustomInput type="switch"
id="switch-manage"
name="manage"
label="Forvalte"
checked={manage}
disabled/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="primary"
onClick={() => {
savePermissions(
groupId,
userId,
{read, write, delete: remove, create});
onClose();
}}
>
Lagre
</Button>
{" "}
<Button color="secondary" onClick={onClose}>Avbryt</Button>
</ModalFooter>
</Modal>
);
}
export default PermissionsModal;

18
webui/src/Components/Pages/AdminPage.jsx

@ -0,0 +1,18 @@
import React from "react";
import Bridges from "../Bridges";
import useAuth from "../../Hooks/auth";
function AdminPage() {
const {user} = useAuth();
if (user.name === "Admin") {
return <></>;
}
return (
<div>
<Bridges/>
</div>
);
}
export default AdminPage;

10
webui/src/Components/Pages/GroupPage.jsx

@ -0,0 +1,10 @@
import React from "react";
import Groups from "../Groups";
function GroupPage() {
return (
<Groups/>
);
}
export default GroupPage;

10
webui/src/Components/Pages/IndexPage.jsx

@ -0,0 +1,10 @@
import React from "react";
import LGroups from "../LGroups";
function IndexPage() {
return (
<LGroups/>
);
}
export default IndexPage;

25
webui/src/Components/Pages/LightPage.jsx

@ -0,0 +1,25 @@
import React from "react";
import useLights from "../../Hooks/light";
import Light from "../Light";
import Loading from "../Loading";
import {Card, CardBody, CardHeader} from "reactstrap";
function LightPage() {
const lights = useLights();
if (lights === null) {
return <Loading/>;
}
return (
<Card className="mt-3">
<CardHeader>
Tilgjengelige lys
</CardHeader>
<CardBody>
{lights.map(light => <Light {...light} />)}
</CardBody>
</Card>
);
}
export default LightPage;

37
webui/src/Components/Structure/Header.jsx

@ -0,0 +1,37 @@
import React, {useState} from "react"
import {Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink} from "reactstrap";
import useAuth from "../../Hooks/auth";
import {Link} from "react-router-dom";
import {onEnter} from "../../Helpers/keys";
export default function Header() {
const [showMenu, setShowMenu] = useState(false);
const {isLoggedIn, logout} = useAuth();
return (
<Navbar color="dark" dark expand="md">
<NavbarBrand tag={Link} to="/">Lucifer</NavbarBrand>
<NavbarToggler onClick={() => setShowMenu(!showMenu)}/>
<Collapse isOpen={showMenu} navbar>
{isLoggedIn && (
<Nav className="ml-auto" navbar>
<NavItem>
<NavLink tag={Link} to="/lights">Lys</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to="/groups">Grupper</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to="/admin">Admin</NavLink>
</NavItem>
<NavItem>
<NavLink tabIndex={0}
onClick={() => logout()}
onKeyDown={onEnter(() => logout())}>Logg ut</NavLink>
</NavItem>
</Nav>
)}
</Collapse>
</Navbar>
);
}

92
webui/src/Helpers/fetcher.js

@ -0,0 +1,92 @@
const PREFIX = "/api";
function formatUrl(url, params = {}) {
const urlWithPrefix = PREFIX + url;
const queryString =
Object
.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
if (queryString.length > 0) {
return urlWithPrefix + "?" + queryString;
}
return urlWithPrefix;
}
/**
* @param {Response} res
* @return {Promise<array<object, object>>}
*/
function authCheck(res) {
return res.json().then(json => {
if (typeof json.data === "undefined") {
json.data = null;
}
if (typeof json.error === "undefined") {
json.error = null;
}
return Promise.resolve(json);
})
}
/**
* @param {string} url
* @param {object} params
* @returns {Promise<object|null>}
*/
export function fetchGet(url, params = {}) {
return fetch(formatUrl(url, params), {
method: "GET",
credentials: "include",
}).then(authCheck);
}
/**
* @param {string} url
* @param {object} params
* @returns {Promise<object|null>}
*/
export function fetchDelete(url, params = {}) {
return fetch(formatUrl(url, params), {
method: "DELETE",
credentials: "include",
}).then(authCheck);
}
/**
* @param {string} url
* @param {object} data
* @returns {Promise<object|null>}
*/
export function fetchPost(url, data = {}) {
return fetch(formatUrl(url), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(data),
}).then(authCheck);
}
/**
* @param {string} url
* @param {object} data
* @returns {Promise<object|null>}
*/
export function fetchPatch(url, data = {}) {
return fetch(formatUrl(url), {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(data),
}).then(authCheck);
}

107
webui/src/Helpers/groups.js

@ -0,0 +1,107 @@
import {randId} from "./random";
import {fetchDelete, fetchGet, fetchPatch, fetchPost} from "./fetcher";
import {nullish} from "./null";
const localData = {};
const callbacks = [];
export function subscribeToGroup(groupId, callback) {
const callbackId = randId();
callbacks.push({callbackId, groupId, callback});
if (groupId >= 0) {
fetchOne(groupId);
} else {
fetchAll();
}
return callbackId;
}
export function unsubscribeFromGroup(callbackId) {
const callback = callbacks.find(c => c !== null && c.callbackId === callbackId);
const index = callbacks.indexOf(callback);
callbacks[index] = null;
}
export function addGroup(name) {
fetchPost("/group/", {name}).then(({data, error}) => {
if (error === null) {
handleGroup(data);
fetchAll();
}
})
}
export function deleteGroup(groupId) {
fetchDelete(`/group/${groupId}`).then(({data, error}) => {
if (error === null) {
fetchAll();
}
});
}
export function saveGroupMetadata(groupId, name) {
fetchPatch(`/group/${groupId}`, {name}).then(({data, error}) => {
if (error === null) {
fetchAll();
}
});
}
export function savePermissions(groupId, userId, permissions) {
fetchPatch(`/group/${groupId}/permission/${userId}`, permissions).then(({data, error}) => {
if (error === null) {
fetchAll();
}
});
}
function fetchAll() {
fetchGet("/group/").then(({data, error}) => {
if (error === null) {
handleGroups(data);
}
});
}
function fetchOne(id) {
fetchGet(`/group/${id}`).then(({data, error}) => {
if (error === null) {
handleGroup(data);
}
});
}
function handleGroups(groups) {
groups.forEach(g => handleGroup(g));
for (let key in localData) {
if (localData.hasOwnProperty(key) && nullish(groups.find(g => g.id === key))) {
delete localData[key];
}
}
dispatch(groups);
}
function handleGroup(group) {
localData[group.id] = group;
dispatch(group);
}
function dispatch(data) {
if (Array.isArray(data)) {
callbacks
.filter(c => c !== null)
.filter(c => c.groupId === -1)
.forEach(c => c.callback(data));
} else {
callbacks
.filter(c => c !== null)
.filter(c => c.groupId === data.id)
.forEach(c => c.callback(data));
}
}

7
webui/src/Helpers/keys.js

@ -0,0 +1,7 @@
export function onEnter(callback) {
return event => {
if (event.keyCode === 13) {
callback();
}
}
}

141
webui/src/Helpers/lights.js

@ -0,0 +1,141 @@
import {randId} from "./random";
import {nullish} from "./null";
import {fetchGet, fetchPatch} from "./fetcher";
const localData = {};
const callbacks = [];
export function subscribeToLight(lightId, callback) {
const callbackId = randId();
callbacks.push({callbackId, lightId, callback});
if (lightId >= 0) {
dispatch();
fetchOne(lightId);
} else {
dispatch();
fetchAll();
}
}
export function unsubscribeFromLight(callbackId) {
const callback = callbacks.find(c => c !== null && c.callbackId === callbackId);
const index = callbacks.indexOf(callback);
callbacks[index] = null;
}
export function changeColor(lightId, newColor, newBrightness, newPower) {
const light = localData[lightId];
if (nullish(light)) {
return;
}
const oldBrightness = light.brightness;
const oldPower = light.on;
const oldColor = light.color;
light.color = newColor;
light.brightness = newBrightness;
light.on = newPower;
dispatch();
fetchPatch(`/light/${lightId}`, {
color: newColor,
brightness: newBrightness,
on: newPower,
}).then(({data, error}) => {
if (error !== null) {
light.color = oldColor;
light.brightness = oldBrightness;
light.on = oldPower;
dispatch();
return;
}
localData[lightId] = data;
dispatch();
});
}
export function changeLight(lightId, name, groupId) {
const light = localData[lightId];
if (nullish(light)) {
return;
}
const oldName = light.name;
const oldGroupId = light.groupId;
light.name = name;
light.groupId = groupId;
dispatch();
console.log({name,groupId});
fetchPatch(`/light/${lightId}`, {
name: name,
groupId: groupId,
}).then(({data, error}) => {
if (error !== null) {
light.name = oldName;
light.groupId = oldGroupId;
dispatch();
return;
}
localData[lightId] = data;
dispatch();
});
}
function fetchAll() {
fetchGet(`/light/`).then(({data, error}) => {
if (error === null) {
handleLights(data);
}
});
}
function fetchOne(id) {
fetchGet(`/light/${id}`).then(({data, error}) => {
if (error === null) {
handleLight(data);
}
});
}
function handleLights(lights) {
lights.forEach(l => handleLight(l));
for (let key in localData) {
if (localData.hasOwnProperty(key) && nullish(lights.find(l => l.id === parseInt(key, 10)))) {
delete localData[key];
}
}
dispatch(lights);
}
function handleLight(light) {
localData[light.id] = light;
dispatch(light);
}
function dispatch(data = null) {
if (data === null) {
data = Object.values(localData);
}
if (Array.isArray(data)) {
callbacks
.filter(c => c !== null)
.filter(c => c.lightId === -1)
.forEach(c => c.callback(data));
} else {
callbacks
.filter(c => c !== null)
.filter(c => c.lightId === data.id)
.forEach(c => c.callback(data));
}
}

7
webui/src/Helpers/null.js

@ -0,0 +1,7 @@
export function nullish(value) {
return typeof value === "undefined" || value === null;
}
export function notNullish(value) {
return !nullish(value);
}

3
webui/src/Helpers/percentage.js

@ -0,0 +1,3 @@
export function percentage(value, max) {
return (100 * value / max).toFixed(1) + " %";
}

12
webui/src/Helpers/random.js

@ -0,0 +1,12 @@
const POSSIBLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const POSSIBLE_LENGTH = POSSIBLE.length;
export function randId() {
let text = "";
for (let i = 0; i < 16; i++) {
text += POSSIBLE.charAt(Math.floor(Math.random() * POSSIBLE_LENGTH));
}
return text;
}

91
webui/src/Hooks/auth.js

@ -0,0 +1,91 @@
import {setGlobal, useGlobal} from "reactn";
import {fetchGet, fetchPost} from "../Helpers/fetcher";
setGlobal({
"auth/login": false,
"auth/checked": false,
"auth/users/me": null,
"auth/users/all": [],
});
export default function useAuth() {
const [isLoggedIn, setIsLoggedIn] = useGlobal("auth/login");
const [isChecked, setIsChecked] = useGlobal("auth/checked");
const [user, setUser] = useGlobal("auth/users/me");
const [users, setUsers] = useGlobal("auth/users/all");
function fetchUsers() {
fetchGet("/user/").then(({data, error}) => {
if (error === null) {
setUsers(data);
}
});
}
function verify() {
setIsChecked(false);
fetchGet("/user/session").then(({data, error}) => {
const validSession = error === null && data.loggedIn;
setIsLoggedIn(validSession);
if (validSession) {
setUser(data.user);
fetchUsers();
}
setIsChecked(true);
})
}
function login(username, password) {
setIsChecked(false);
fetchPost("/user/login", {username, password}).then(({data, error}) => {
setIsChecked(true);
if (error !== null) {
setIsLoggedIn(false);
return;
}
setIsLoggedIn(true);
setIsLoggedIn(data);
fetchUsers();
});
}
function logout() {
setIsChecked(false);
fetchPost("/user/logout").then(({data, error}) => {
setIsChecked(true);
if (error !== null) {
setIsLoggedIn(false);
return;
// TODO: Show errors ¿?
}
setIsLoggedIn(false);
});
}
function register(username, password, repeated) {
setIsChecked(false);
if (password !== repeated) {
alert("Passordene er ikke like");
return;
}
fetchPost("/user/register", {username, password}).then(({data, error}) => {
if (error !== null) {
login(username, password);
}
})
}
return {isLoggedIn, isChecked, user, users, verify, login, logout, register};
}

99
webui/src/Hooks/bridge.js

@ -0,0 +1,99 @@
import {setGlobal, useGlobal} from "reactn";
import {useEffect} from "react";
import {fetchDelete, fetchGet, fetchPatch, fetchPost} from "../Helpers/fetcher";
import useLights from "./lights";
setGlobal({
bridges: null,
});
function useBridges() {
const [bridges, setBridges] = useGlobal("bridges");
const {reloadLights} = useLights();
function reloadBridges() {
setBridges(null);
fetchGet("/bridge/").then(({error, data}) => {
if (error !== null) {
console.error(error);
return;
}
setBridges(data);
});
}
useEffect(() => {
if (bridges === null) {
reloadBridges();
}
}, []);
function bridge(id) {
if (bridges === null) {
return null;
}
return bridges.find(b => b.id === id);
}
function forgetBridge(id) {
fetchDelete(`/bridge/${id}`).then(({data, error}) => {
if (error !== null) {
console.error(error);
}
reloadBridges();
});
}
function forgetLight(bridgeId, lightId) {
fetchDelete(`/bridge/${bridgeId}/light/${lightId}`).then(({data, error}) => {
if (error !== null) {
console.error(error);
}
reloadBridges();
});
}
function addBridge(name, driver, addr, callback = null) {
fetchPost("/bridge/", {name, driver, addr}).then(({error}) => {
if (error !== null) {
console.error(error);
}
if (callback !== null) {
callback(error === null);
}
reloadBridges();
});
}
function editBridge(id, name) {
fetchPatch(`/bridge/${id}`, {name}).then(({error}) => {
if (error !== null) {
console.error(error);
}
reloadBridges();
});
}
function discoverLights(id) {
fetchPost(`/bridge/${id}/discover`).then(({error}) => {
if (error !== null) {
console.error(error);
}
reloadLights();
reloadBridges();
});
}
return {bridges, bridge, forgetBridge, addBridge, editBridge, forgetLight, discoverLights};
}
export default useBridges;

20
webui/src/Hooks/group.js

@ -0,0 +1,20 @@
import {useEffect, useState} from "react";
import {subscribeToGroup, unsubscribeFromGroup} from "../Helpers/groups";
export default function useGroups(id = -1) {
const [group, setGroup] = useState(null);
function onChange(group) {
setGroup(group);
}
useEffect(() => {
const cbId = subscribeToGroup(id, onChange);
return () => {
unsubscribeFromGroup(cbId);
};
}, []);
return group;
}

24
webui/src/Hooks/light.js

@ -0,0 +1,24 @@
import {useEffect, useState} from "react";
import {subscribeToLight, unsubscribeFromLight} from "../Helpers/lights";
export default function useLights({groupId = -1, id = -1} = {}) {
const [light, setLight] = useState(null);
function onChange(light) {
setLight(light);
}
useEffect(() => {
const cbId = subscribeToLight(id, onChange);
return () => {
unsubscribeFromLight(cbId);
};
}, []);
if (groupId >= 0 && light !== null) {
return light.filter(l => l.groupId === groupId);
}
return light;
}

42
webui/src/Hooks/lights.js

@ -0,0 +1,42 @@
import {setGlobal, useGlobal} from "reactn";
import {fetchGet} from "../Helpers/fetcher";
import {useEffect} from "react";
setGlobal({
lights: null,
});
function useLights() {
const [lights, setLights] = useGlobal("lights");
function reloadLights() {
setLights(null);
fetchGet("/light/").then(({error, data}) => {
if (error !== null) {
console.error(error);
return;
}
setLights(data);
});
}
useEffect(() => {
if (lights === null) {
reloadLights();
}
}, []);
function lightsByBridge(bridgeId) {
if (lights === null) {
return null;
}
return lights.filter(light => light.bridgeId === bridgeId);
}
return {lights, lightsByBridge, reloadLights};
}
export default useLights;

33
webui/src/Reducers/authReducer.js

@ -1,33 +0,0 @@
const initialState = {
isChecked: false,
isLoggedIn: false,
};
const VERIFICATION_STARTED = "auth/verification/started";
const VERIFICATION_SUCCESS = "auth/verification/changed";
const VERIFICATION_FAILED = "auth/verification/failed";
const authReducer = (state = initialState, {type, payload} = {}) => {
switch (type) {
case VERIFICATION_STARTED:
return initialState;
case VERIFICATION_SUCCESS:
return {
isChecked: true,
isLoggedIn: true,
};
case VERIFICATION_FAILED:
return {
isChecked: true,
isLoggedIn: true,
};
default:
return state;
}
};
export const verificationStartedEvent = () => ({ type: VERIFICATION_STARTED });
export const verificationSucceededEvent = () => ({ type: VERIFICATION_SUCCESS });
export const verificationFailedEvent = () => ({ type: VERIFICATION_FAILED });
export default authReducer;

10
webui/src/Reducers/index.js

@ -1,10 +0,0 @@
import {combineReducers, createStore} from "redux";
import authReducer from "./authReducer";
const rootReducer = combineReducers({
auth: authReducer,
});
const store = createStore(rootReducer);
export default store;

10
webui/src/index.js

@ -3,13 +3,5 @@ import ReactDOM from "react-dom";
import App from "./App";
import "bootstrap/dist/css/bootstrap.min.css";
import {Provider} from "react-redux";
import store from "./Reducers";
ReactDOM.render(
(
<Provider store={store}>
<App/>
</Provider>
),
document.getElementById("root"));
ReactDOM.render(<App/>, document.getElementById("root"));

15
webui/src/setupProxy.js

@ -0,0 +1,15 @@
const fs = require("fs");
const proxy = require("http-proxy-middleware");
let config = {url: "http://10.12.121.228:8100/"};
try {
const data = fs.readFileSync("./proxy.json", "utf-8");
config = JSON.parse(data)
} catch(err) {
console.error("Failed to load proxy.json in project root");
}
module.exports = function(app) {
app.use(proxy('/api/ws', { target: config.url, ws: true }));
app.use(proxy('/api', { target: config.url, ws: false }));
};
Loading…
Cancel
Save